Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions apps/host/src/preview-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/dotns-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -223,6 +231,7 @@ export const dotNsUrl = {
isDotDomain,
isProductIdentifier,
isWebcontainerPreviewHost,
isTrustedPreviewHost,
parseDotNsDomain,
parseLocalhostUrl,
normalizeUrl,
Expand Down
31 changes: 31 additions & 0 deletions packages/shared/tests/dotns-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
7 changes: 7 additions & 0 deletions packages/ui/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ export async function renderIframe(url: string, label: string): Promise<void> {
"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;
Expand Down Expand Up @@ -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 =
Expand Down
16 changes: 6 additions & 10 deletions packages/ui/src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -1410,15 +1405,16 @@ 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;
const allowed =
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}`,
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/topbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
4 changes: 3 additions & 1 deletion scripts/preview-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading