Skip to content

Commit f0f03c0

Browse files
committed
Allow trusted preview hosts
1 parent 0b55700 commit f0f03c0

7 files changed

Lines changed: 65 additions & 16 deletions

File tree

apps/host/src/preview-route.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,16 @@ export function parsePreviewTargetUrl(
2121
// A localhost preview target is only honoured in debug builds. Proxying a
2222
// visitor's localhost into the trusted host origin is gated behind the
2323
// build-time `VITE_APP_DEBUG` flag (`DEBUG`); production builds (flag unset)
24-
// never honour a localhost target. Webcontainer preview hosts are always
25-
// allowed — they are public https origins, not loopback.
24+
// never honour a localhost target. Trusted preview hosts are always allowed
25+
// — they are public https origins, not loopback.
2626
const targetIsAllowedLocalhost =
2727
DEBUG && dotNsUrl.parseLocalhostUrl(target.toString()) !== null;
28-
const isWebContainer =
28+
const isTrustedPreview =
2929
target.protocol === "https:" &&
30-
dotNsUrl.isWebcontainerPreviewHost(target.hostname);
30+
dotNsUrl.isTrustedPreviewHost(target.hostname);
3131

3232
if (
33-
(!targetIsAllowedLocalhost && !isWebContainer) ||
33+
(!targetIsAllowedLocalhost && !isTrustedPreview) ||
3434
target.username ||
3535
target.password
3636
) {

packages/shared/src/dotns-url.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ function isWebcontainerPreviewHost(host: string): boolean {
5454
return host.toLowerCase().endsWith(".webcontainer-api.io");
5555
}
5656

57+
function isRevxPreviewHost(host: string): boolean {
58+
return host.toLowerCase().endsWith(".preview.revx.dev");
59+
}
60+
61+
function isTrustedPreviewHost(host: string): boolean {
62+
return isWebcontainerPreviewHost(host) || isRevxPreviewHost(host);
63+
}
64+
5765
function parseUrl(url: string): URL | null {
5866
try {
5967
return new URL(url);
@@ -223,6 +231,7 @@ export const dotNsUrl = {
223231
isDotDomain,
224232
isProductIdentifier,
225233
isWebcontainerPreviewHost,
234+
isTrustedPreviewHost,
226235
parseDotNsDomain,
227236
parseLocalhostUrl,
228237
normalizeUrl,

packages/shared/tests/dotns-url.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,3 +329,34 @@ describe("isWebcontainerPreviewHost", () => {
329329
);
330330
});
331331
});
332+
333+
describe("isTrustedPreviewHost", () => {
334+
it("matches webcontainer preview hosts", () => {
335+
expect(
336+
dotNsUrl.isTrustedPreviewHost(
337+
"abc--3000--def.local-credentialless.webcontainer-api.io",
338+
),
339+
).toBe(true);
340+
});
341+
342+
it("matches RevX session preview subdomains", () => {
343+
expect(dotNsUrl.isTrustedPreviewHost("test.preview.revx.dev")).toBe(true);
344+
expect(
345+
dotNsUrl.isTrustedPreviewHost("a1b2c3.sessions.preview.revx.dev"),
346+
).toBe(true);
347+
});
348+
349+
it("is case-insensitive", () => {
350+
expect(dotNsUrl.isTrustedPreviewHost("TEST.PREVIEW.REVX.DEV")).toBe(true);
351+
});
352+
353+
it("rejects look-alike and untrusted hosts", () => {
354+
expect(dotNsUrl.isTrustedPreviewHost("preview.revx.dev")).toBe(false);
355+
expect(dotNsUrl.isTrustedPreviewHost("evil-preview.revx.dev")).toBe(false);
356+
expect(
357+
dotNsUrl.isTrustedPreviewHost("test.preview.revx.dev.attacker.com"),
358+
).toBe(false);
359+
expect(dotNsUrl.isTrustedPreviewHost("mytestapp.dot")).toBe(false);
360+
expect(dotNsUrl.isTrustedPreviewHost("localhost:3000")).toBe(false);
361+
});
362+
});

packages/ui/src/bridge.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@ export async function renderIframe(url: string, label: string): Promise<void> {
160160
"allow-same-origin",
161161
"allow-forms",
162162
"allow-pointer-lock",
163+
"allow-popups",
164+
"allow-popups-to-escape-sandbox",
165+
"allow-top-navigation-by-user-activation",
166+
"allow-top-navigation-to-custom-protocols",
163167
);
164168
iframe.allow = `${buildAllowAttribute(label)}; cross-origin-isolated`;
165169
iframe.style.cssText = iframeStyle;
@@ -320,6 +324,9 @@ export async function renderAppSubdomain(
320324
"allow-forms",
321325
"allow-pointer-lock",
322326
"allow-popups",
327+
"allow-popups-to-escape-sandbox",
328+
"allow-top-navigation-by-user-activation",
329+
"allow-top-navigation-to-custom-protocols",
323330
);
324331
iframe.allow = buildAllowAttribute(label);
325332
iframe.style.cssText =

packages/ui/src/container.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -120,20 +120,15 @@ function subscribeSession(
120120
});
121121
}
122122

123-
// localhost proxy and webcontainer previews are developer affordances for
124-
// running a `.dot` app before it's deployed.
123+
// Dev preview labels cover localhost and trusted preview hosts.
125124
function isDevPreviewLabel(label: string): boolean {
126-
return (
127-
label.startsWith("localhost:") || dotNsUrl.isWebcontainerPreviewHost(label)
128-
);
125+
return label.startsWith("localhost:") || dotNsUrl.isTrustedPreviewHost(label);
129126
}
130127

131128
/**
132129
* Derive the product-id from an iframe label.
133130
*
134-
* Dev previews (localhost proxy, webcontainer) keep the bare host label. dotNs
135-
* products get the `.dot` suffix appended. Same rule encoded in
136-
* `isProductAccountValid`.
131+
* Dev preview labels stay bare; deployed products get `.dot`.
137132
*/
138133
function labelToProductIdentifier(label: string): string {
139134
return isDevPreviewLabel(label) ? label : `${label}.dot`;
@@ -1410,15 +1405,16 @@ export function setupNestedBridgeDetector(
14101405
return;
14111406
}
14121407

1413-
// Validate origin: only allow *.dot.li and *.localhost origins
1408+
// Allow same-domain, localhost, and trusted preview origins.
14141409
try {
14151410
const url = new URL(event.origin);
14161411
const h = url.hostname;
14171412
const allowed =
14181413
h.endsWith(`.${BASE_DOMAIN}`) ||
14191414
h === BASE_DOMAIN ||
14201415
h === "localhost" ||
1421-
h.endsWith(".localhost");
1416+
h.endsWith(".localhost") ||
1417+
dotNsUrl.isTrustedPreviewHost(h);
14221418
if (!allowed) {
14231419
log.warn(
14241420
`[dot.li] Rejected nested bridge from disallowed origin: ${event.origin}`,

packages/ui/src/topbar.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,12 +404,16 @@ function renderPairing(payload: string): void {
404404

405405
const qrLink = document.createElement("a");
406406
qrLink.href = payload;
407+
qrLink.target = "_top";
408+
qrLink.rel = "noopener";
407409
qrLink.className = "auth-modal-qr-link";
408410
qrLink.appendChild(canvas);
409411
qrLink.hidden = true;
410412

411413
const openApp = document.createElement("a");
412414
openApp.href = payload;
415+
openApp.target = "_top";
416+
openApp.rel = "noopener";
413417
openApp.className = "auth-modal-open-app";
414418
openApp.textContent = "Login With Polkadot App";
415419

scripts/preview-server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,9 @@ Bun.serve({
168168
return handleModeSync(req, key);
169169
}
170170

171-
const isProtocol = url.hostname === "host.localhost";
171+
// Protocol builds live on any `host.` subdomain, including wildcard domains
172+
// used for mobile previews.
173+
const isProtocol = url.hostname.startsWith("host.");
172174
const isApp = url.hostname.includes(".app.");
173175
const baseDir = isProtocol ? PROTOCOL_DIR : isApp ? APP_DIR : HOST_DIR;
174176
const fallback = "index.html";

0 commit comments

Comments
 (0)