diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index d3c7f6a8dab..6f89d86df88 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -25,6 +25,89 @@ describe("browser target resolver", () => { }); }); + it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:5173/dashboard?mode=test#results", + }), + ).toEqual({ + requestedUrl: "http://localhost:5173/dashboard?mode=test#results", + resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results", + resolutionKind: "direct-private-network", + environmentId: "environment-1", + }); + }); + + it("preserves URL credentials when mapping localhost onto a remote host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://user:p%40ss@localhost:5173/dashboard", + }).resolvedUrl, + ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard"); + }); + + it("maps credentialed localhost URLs onto private IPv6 hosts", async () => { + readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", + }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", + }).resolvedUrl, + ).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results"); + }); + + it("maps schemeless localhost navigation onto a remote environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "localhost:3000/app", + }).resolvedUrl, + ).toBe("http://192.168.1.25:3000/app"); + }); + + it("keeps localhost navigation local for a local environment", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "localhost:3000/app", + }), + ).toEqual({ + requestedUrl: "localhost:3000/app", + resolvedUrl: "localhost:3000/app", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + + it("keeps localhost navigation local for the full IPv4 loopback range", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.2:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:3000/app", + }), + ).toEqual({ + requestedUrl: "http://localhost:3000/app", + resolvedUrl: "http://localhost:3000/app", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + it("refuses public relay hosts until the authenticated gateway exists", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); @@ -34,6 +117,12 @@ describe("browser target resolver", () => { port: 5173, }), ).toThrow(/authenticated preview gateway/); + expect(() => + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:5173", + }), + ).toThrow(/authenticated preview gateway/); }); it("normalizes schemeless localhost server-picker values", async () => { @@ -63,7 +152,9 @@ describe("browser target resolver", () => { }); it("supports private IPv6 environment hosts", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); + readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", + }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { @@ -71,7 +162,18 @@ describe("browser target resolver", () => { port: 5173, path: "/app?mode=test", }).resolvedUrl, - ).toBe("http://[::1]:5173/app?mode=test"); + ).toBe("http://[fd7a:115c:a1e0::53]:5173/app?mode=test"); + }); + + it("supports a local IPv6 environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + }).resolvedUrl, + ).toBe("http://[::1]:5173/"); }); it("leaves malformed input for the normal navigation error path", async () => { diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 9142cce1e72..9b201dbdbae 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -7,43 +7,60 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { readPreparedConnection } from "~/state/session"; +const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); + +const parseIpv4Address = (host: string): readonly number[] | null => { + const parts = normalizeHostname(host).split(".").map(Number); + return parts.length === 4 && + parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) + ? parts + : null; +}; + +const isLocalLoopbackHost = (host: string): boolean => { + const normalized = normalizeHostname(host); + if (normalized === "localhost" || normalized === "::1") return true; + return parseIpv4Address(normalized)?.[0] === 127; +}; + const isPrivateNetworkHost = (host: string): boolean => { - const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); - if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) { + const normalized = normalizeHostname(host); + if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) { return true; } if (normalized.endsWith(".ts.net")) return true; - const parts = normalized.split(".").map(Number); - if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; + const parts = parseIpv4Address(normalized); + if (parts) { + return ( + parts[0] === 10 || + (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || + (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 169 && parts[1] === 254) + ); + } + const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; + if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; + const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); return ( - parts[0] === 10 || - (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || - (parts[0] === 192 && parts[1] === 168) || - parts[0] === 127 || - (parts[0] === 169 && parts[1] === 254) + Number.isInteger(firstIpv6Hextet) && + ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80) ); }; -const isLocalLoopbackHost = (host: string): boolean => { - const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); - return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; +const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + return new URL(connection.httpBaseUrl); }; -export function resolveBrowserNavigationTarget( +const resolveEnvironmentPortTarget = ( environmentId: EnvironmentId, - target: BrowserNavigationTarget, -): PreviewUrlResolution { - if (target.kind === "url") { - return { - requestedUrl: target.url, - resolvedUrl: target.url, - resolutionKind: "direct", - environmentId, - }; - } - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - const environmentUrl = new URL(connection.httpBaseUrl); + target: Extract, + environmentUrl: URL, + requestedUrl?: string, + sourceUrl?: URL, +): PreviewUrlResolution => { if (!isPrivateNetworkHost(environmentUrl.hostname)) { throw new Error( "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", @@ -51,40 +68,72 @@ export function resolveBrowserNavigationTarget( } const protocol = target.protocol ?? "http"; const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; - const requestedUrl = `${protocol}://localhost:${target.port}${path}`; const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); const resolvedHost = normalizedEnvironmentHost.includes(":") ? `[${normalizedEnvironmentHost}]` : normalizedEnvironmentHost; - const resolved = new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + const resolved = sourceUrl + ? new URL(sourceUrl) + : new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + if (sourceUrl) { + resolved.hostname = resolvedHost; + resolved.port = String(target.port); + } return { - requestedUrl, + requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`, resolvedUrl: resolved.toString(), - resolutionKind: - normalizedEnvironmentHost === "localhost" || normalizedEnvironmentHost === "127.0.0.1" - ? "direct" - : "direct-private-network", + resolutionKind: isLocalLoopbackHost(normalizedEnvironmentHost) + ? "direct" + : "direct-private-network", environmentId, }; +}; + +export function resolveBrowserNavigationTarget( + environmentId: EnvironmentId, + target: BrowserNavigationTarget, +): PreviewUrlResolution { + if (target.kind === "url") { + let parsed: URL | null = null; + try { + parsed = new URL(normalizePreviewUrl(target.url)); + } catch { + // Preserve the existing direct-navigation behavior so the preview host + // reports malformed URL errors through its normal navigation path. + } + if (parsed && isLoopbackHost(parsed.hostname)) { + const environmentUrl = readEnvironmentUrl(environmentId); + if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) { + return resolveEnvironmentPortTarget( + environmentId, + { + kind: "environment-port", + port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }, + environmentUrl, + target.url, + parsed, + ); + } + } + return { + requestedUrl: target.url, + resolvedUrl: target.url, + resolutionKind: "direct", + environmentId, + }; + } + return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId)); } export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { try { const normalizedUrl = normalizePreviewUrl(rawUrl); - const parsed = new URL(normalizedUrl); - if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - const environmentUrl = new URL(connection.httpBaseUrl); - if (parsed.hostname !== "0.0.0.0" && isLocalLoopbackHost(environmentUrl.hostname)) { - return normalizedUrl; - } - const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); return resolveBrowserNavigationTarget(environmentId, { - kind: "environment-port", - port, - protocol: parsed.protocol === "https:" ? "https" : "http", - path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + kind: "url", + url: normalizedUrl, }).resolvedUrl; } catch { return rawUrl; diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index d637611a786..06954b73f37 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -343,6 +343,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) return await currentStatus(threadRef, tabId); case "open": { const input = request.input as PreviewAutomationOpenInput; + const resolvedInputUrl = input.url + ? resolveBrowserNavigationTarget(environmentId, { + kind: "url", + url: input.url, + }).resolvedUrl + : undefined; let activeTabId = resolvePreviewAutomationOpenTab( state, request.tabId, @@ -358,7 +364,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) environmentId, input: { threadId: request.threadId, - ...(input.url ? { url: input.url } : {}), + ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), }, }); if (result._tag === "Failure") { @@ -381,12 +387,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) request.timeoutMs, ); } - if (reusedExistingTab && input.url && previewBridge) { - const resolution = resolveBrowserNavigationTarget(environmentId, { - kind: "url", - url: input.url, - }); - await previewBridge.navigate(activeTabId, resolution.resolvedUrl); + if (reusedExistingTab && resolvedInputUrl && previewBridge) { + await previewBridge.navigate(activeTabId, resolvedInputUrl); await waitForNavigationReadiness( threadRef, request.requestId,