From b47eaf517ae5cfc6940e94533af1eca10102e173 Mon Sep 17 00:00:00 2001 From: Talles Borges Date: Wed, 17 Jun 2026 07:49:43 +0200 Subject: [PATCH] Allow trusted preview hosts --- apps/host/src/preview-route.ts | 10 ++++---- packages/shared/src/dotns-url.ts | 9 +++++++ packages/shared/tests/dotns-url.test.ts | 31 +++++++++++++++++++++++++ packages/ui/src/bridge.ts | 7 ++++++ packages/ui/src/container.ts | 16 +++++-------- packages/ui/src/topbar.ts | 4 ++++ scripts/preview-server.ts | 4 +++- 7 files changed, 65 insertions(+), 16 deletions(-) diff --git a/apps/host/src/preview-route.ts b/apps/host/src/preview-route.ts index bfb0eb96..7461650c 100644 --- a/apps/host/src/preview-route.ts +++ b/apps/host/src/preview-route.ts @@ -21,16 +21,16 @@ export function parsePreviewTargetUrl( // A localhost preview target is only honoured in debug builds. Proxying a // visitor's localhost into the trusted host origin is gated behind the // build-time `VITE_APP_DEBUG` flag (`DEBUG`); production builds (flag unset) - // never honour a localhost target. Webcontainer preview hosts are always - // allowed — they are public https origins, not loopback. + // never honour a localhost target. Trusted preview hosts are always allowed + // — they are public https origins, not loopback. const targetIsAllowedLocalhost = DEBUG && dotNsUrl.parseLocalhostUrl(target.toString()) !== null; - const isWebContainer = + const isTrustedPreview = target.protocol === "https:" && - dotNsUrl.isWebcontainerPreviewHost(target.hostname); + dotNsUrl.isTrustedPreviewHost(target.hostname); if ( - (!targetIsAllowedLocalhost && !isWebContainer) || + (!targetIsAllowedLocalhost && !isTrustedPreview) || target.username || target.password ) { diff --git a/packages/shared/src/dotns-url.ts b/packages/shared/src/dotns-url.ts index 6dc5a400..7b7541dd 100644 --- a/packages/shared/src/dotns-url.ts +++ b/packages/shared/src/dotns-url.ts @@ -54,6 +54,14 @@ function isWebcontainerPreviewHost(host: string): boolean { return host.toLowerCase().endsWith(".webcontainer-api.io"); } +function isRevxPreviewHost(host: string): boolean { + return host.toLowerCase().endsWith(".preview.revx.dev"); +} + +function isTrustedPreviewHost(host: string): boolean { + return isWebcontainerPreviewHost(host) || isRevxPreviewHost(host); +} + function parseUrl(url: string): URL | null { try { return new URL(url); @@ -223,6 +231,7 @@ export const dotNsUrl = { isDotDomain, isProductIdentifier, isWebcontainerPreviewHost, + isTrustedPreviewHost, parseDotNsDomain, parseLocalhostUrl, normalizeUrl, diff --git a/packages/shared/tests/dotns-url.test.ts b/packages/shared/tests/dotns-url.test.ts index b2edfecd..31fc0487 100644 --- a/packages/shared/tests/dotns-url.test.ts +++ b/packages/shared/tests/dotns-url.test.ts @@ -329,3 +329,34 @@ describe("isWebcontainerPreviewHost", () => { ); }); }); + +describe("isTrustedPreviewHost", () => { + it("matches webcontainer preview hosts", () => { + expect( + dotNsUrl.isTrustedPreviewHost( + "abc--3000--def.local-credentialless.webcontainer-api.io", + ), + ).toBe(true); + }); + + it("matches RevX session preview subdomains", () => { + expect(dotNsUrl.isTrustedPreviewHost("test.preview.revx.dev")).toBe(true); + expect( + dotNsUrl.isTrustedPreviewHost("a1b2c3.sessions.preview.revx.dev"), + ).toBe(true); + }); + + it("is case-insensitive", () => { + expect(dotNsUrl.isTrustedPreviewHost("TEST.PREVIEW.REVX.DEV")).toBe(true); + }); + + it("rejects look-alike and untrusted hosts", () => { + expect(dotNsUrl.isTrustedPreviewHost("preview.revx.dev")).toBe(false); + expect(dotNsUrl.isTrustedPreviewHost("evil-preview.revx.dev")).toBe(false); + expect( + dotNsUrl.isTrustedPreviewHost("test.preview.revx.dev.attacker.com"), + ).toBe(false); + expect(dotNsUrl.isTrustedPreviewHost("mytestapp.dot")).toBe(false); + expect(dotNsUrl.isTrustedPreviewHost("localhost:3000")).toBe(false); + }); +}); diff --git a/packages/ui/src/bridge.ts b/packages/ui/src/bridge.ts index 8dc420ee..ac54fdca 100644 --- a/packages/ui/src/bridge.ts +++ b/packages/ui/src/bridge.ts @@ -160,6 +160,10 @@ export async function renderIframe(url: string, label: string): Promise { "allow-same-origin", "allow-forms", "allow-pointer-lock", + "allow-popups", + "allow-popups-to-escape-sandbox", + "allow-top-navigation-by-user-activation", + "allow-top-navigation-to-custom-protocols", ); iframe.allow = `${buildAllowAttribute(label)}; cross-origin-isolated`; iframe.style.cssText = iframeStyle; @@ -320,6 +324,9 @@ export async function renderAppSubdomain( "allow-forms", "allow-pointer-lock", "allow-popups", + "allow-popups-to-escape-sandbox", + "allow-top-navigation-by-user-activation", + "allow-top-navigation-to-custom-protocols", ); iframe.allow = buildAllowAttribute(label); iframe.style.cssText = diff --git a/packages/ui/src/container.ts b/packages/ui/src/container.ts index e6aaf4f4..0616871e 100644 --- a/packages/ui/src/container.ts +++ b/packages/ui/src/container.ts @@ -120,20 +120,15 @@ function subscribeSession( }); } -// localhost proxy and webcontainer previews are developer affordances for -// running a `.dot` app before it's deployed. +// Dev preview labels cover localhost and trusted preview hosts. function isDevPreviewLabel(label: string): boolean { - return ( - label.startsWith("localhost:") || dotNsUrl.isWebcontainerPreviewHost(label) - ); + return label.startsWith("localhost:") || dotNsUrl.isTrustedPreviewHost(label); } /** * Derive the product-id from an iframe label. * - * Dev previews (localhost proxy, webcontainer) keep the bare host label. dotNs - * products get the `.dot` suffix appended. Same rule encoded in - * `isProductAccountValid`. + * Dev preview labels stay bare; deployed products get `.dot`. */ function labelToProductIdentifier(label: string): string { return isDevPreviewLabel(label) ? label : `${label}.dot`; @@ -1410,7 +1405,7 @@ export function setupNestedBridgeDetector( return; } - // Validate origin: only allow *.dot.li and *.localhost origins + // Allow same-domain, localhost, and trusted preview origins. try { const url = new URL(event.origin); const h = url.hostname; @@ -1418,7 +1413,8 @@ export function setupNestedBridgeDetector( h.endsWith(`.${BASE_DOMAIN}`) || h === BASE_DOMAIN || h === "localhost" || - h.endsWith(".localhost"); + h.endsWith(".localhost") || + dotNsUrl.isTrustedPreviewHost(h); if (!allowed) { log.warn( `[dot.li] Rejected nested bridge from disallowed origin: ${event.origin}`, diff --git a/packages/ui/src/topbar.ts b/packages/ui/src/topbar.ts index c78dc5fd..8a84ecf3 100644 --- a/packages/ui/src/topbar.ts +++ b/packages/ui/src/topbar.ts @@ -404,12 +404,16 @@ function renderPairing(payload: string): void { const qrLink = document.createElement("a"); qrLink.href = payload; + qrLink.target = "_top"; + qrLink.rel = "noopener"; qrLink.className = "auth-modal-qr-link"; qrLink.appendChild(canvas); qrLink.hidden = true; const openApp = document.createElement("a"); openApp.href = payload; + openApp.target = "_top"; + openApp.rel = "noopener"; openApp.className = "auth-modal-open-app"; openApp.textContent = "Login With Polkadot App"; diff --git a/scripts/preview-server.ts b/scripts/preview-server.ts index 76bc21b6..c86a9768 100644 --- a/scripts/preview-server.ts +++ b/scripts/preview-server.ts @@ -168,7 +168,9 @@ Bun.serve({ return handleModeSync(req, key); } - const isProtocol = url.hostname === "host.localhost"; + // Protocol builds live on any `host.` subdomain, including wildcard domains + // used for mobile previews. + const isProtocol = url.hostname.startsWith("host."); const isApp = url.hostname.includes(".app."); const baseDir = isProtocol ? PROTOCOL_DIR : isApp ? APP_DIR : HOST_DIR; const fallback = "index.html";