From fb5165bb26b2563c9b4942abac27343014155ff8 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:57:27 +0200 Subject: [PATCH] fix: keep integrations and live screen working on a LAN origin (#139) Opening the web app over the Mac's Wi-Fi address (an insecure context) crashed Connect because crypto.randomUUID is unavailable there, and the MCP OAuth redirect, Composio/Pipedream return and noVNC link all pointed at WEB_ORIGIN (127.0.0.1). The API now derives these URLs from the request's browser origin when it is loopback or a private network, the same hosts CORS already trusts, and falls back to WEB_ORIGIN otherwise. Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD Co-authored-by: Claude --- apps/api/src/app.ts | 22 ++---- apps/api/src/router.test.ts | 67 ++++++++++++++++++- apps/api/src/router.ts | 31 ++++++--- apps/api/src/web-origin.test.ts | 28 ++++++++ apps/api/src/web-origin.ts | 30 +++++++++ .../integrations/IntegrationSetup.tsx | 3 +- 6 files changed, 153 insertions(+), 28 deletions(-) create mode 100644 apps/api/src/web-origin.test.ts create mode 100644 apps/api/src/web-origin.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index fd2f524..ac225d4 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -118,6 +118,7 @@ import { TEAM_CHAT_STARTUP_SHUTDOWN_MS, } from "./team-chat-startup.js"; import { mountVoiceHttpRoutes } from "./voice.js"; +import { isLoopbackHost, isPrivateOrLocalHost, requestWebOrigin } from "./web-origin.js"; import { mountWebhookHttpRoutes } from "./webhook.js"; export interface AppHandles { @@ -590,7 +591,12 @@ export async function createApp( } const { matched, response } = await rpc.handle(c.req.raw, { prefix: "/rpc", - context: { actor, signal: c.req.raw.signal, userAgent: c.req.header("user-agent") }, + context: { + actor, + signal: c.req.raw.signal, + userAgent: c.req.header("user-agent"), + webOrigin: requestWebOrigin(c.req.header("origin"), env.webOrigin), + }, }); if (matched) return c.newResponse(response.body, response); await next(); @@ -1007,20 +1013,6 @@ function isTrustedOrigin(origin: string, env: AppEnv) { } } -function isPrivateOrLocalHost(host: string): boolean { - if (isLoopbackHost(host)) return true; - if (host.endsWith(".local")) return true; - if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(host)) return true; - if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true; - if (/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(host)) return true; - if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}$/.test(host)) return true; - return false; -} - -function isLoopbackHost(host: string): boolean { - return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]"; -} - function sessionHeaders(request: Request) { const headers = new Headers(request.headers); const authz = headers.get("authorization"); diff --git a/apps/api/src/router.test.ts b/apps/api/src/router.test.ts index e821a2e..a43a74b 100644 --- a/apps/api/src/router.test.ts +++ b/apps/api/src/router.test.ts @@ -627,7 +627,11 @@ describe("computer screen url", () => { controlRunId: null, }; - const callScreenUrl = async (connectScreen: () => Promise, updateMany = vi.fn()) => { + const callScreenUrl = async ( + connectScreen: () => Promise, + updateMany = vi.fn(), + webOrigin?: string, + ) => { const prisma = { bot: { findFirst: vi.fn().mockResolvedValue({ @@ -660,7 +664,7 @@ describe("computer screen url", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ json: { botId: "bot-1" } }), }), - { prefix: "/rpc", context: { actor } }, + { prefix: "/rpc", context: { actor, webOrigin } }, ); return { response, updateMany }; }; @@ -685,6 +689,16 @@ describe("computer screen url", () => { }); }); + it("points the screen at the browser's own LAN origin", async () => { + const { response } = await callScreenUrl( + async () => ({ url: "https://screen.example/vnc.html?token=fake-token" }), + vi.fn(), + "http://192.168.0.20:5173", + ); + const { json } = await response.json(); + expect(new URL(json.url).origin).toBe("http://192.168.0.20:5173"); + }); + it("returns desktop provider screen URLs without sealing them", async () => { const { response } = await callScreenUrl(async () => ({ url: "desktop://screen/computer-1", @@ -977,3 +991,52 @@ describe("interrupted computer reservation release", () => { expect(order).toEqual(["operation", "computer"]); }); }); + +describe("mcp oauth redirect", () => { + const begin = async (redirectUri: string, webOrigin?: string) => { + const start = vi.fn().mockResolvedValue({ status: "already_connected" }); + const deps = { + prisma: {}, + env: { webOrigin: "http://127.0.0.1:5173" }, + mcpOAuth: { begin: start }, + } as unknown as RouterDeps; + const { response } = await new RPCHandler(createRouter(deps)).handle( + new Request("http://127.0.0.1/rpc/mcp/oauth/begin", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ json: { serverId: "server-1", redirectUri } }), + }), + { + prefix: "/rpc", + context: { + actor: { + userId: "user", + spaceId: "space", + email: "user@rakazo.test", + isDeploymentOwner: true, + }, + webOrigin, + }, + }, + ); + return { status: response.status, start }; + }; + + it("returns to the LAN origin the browser opened", async () => { + const { status, start } = await begin( + "http://192.168.0.20:5173/mcp/oauth/callback", + "http://192.168.0.20:5173", + ); + expect(status).toBe(200); + expect(start).toHaveBeenCalled(); + }); + + it("rejects a redirect to any other origin", async () => { + const { status, start } = await begin( + "https://attacker.example/mcp/oauth/callback", + "http://192.168.0.20:5173", + ); + expect(status).toBe(400); + expect(start).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 0e77ae1..9cd74ff 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -574,8 +574,11 @@ export function createRouter(deps: RouterDeps) { actor: Actor | null; signal?: AbortSignal; userAgent?: string; + /** Browser origin for URLs handed back to this request; see requestWebOrigin. */ + webOrigin?: string; }>(); const repos = createRepos(deps.prisma); + const webOriginFor = (context: { webOrigin?: string }) => context.webOrigin ?? deps.env.webOrigin; const onboardingDeps = { prisma: deps.prisma, events: deps.events, @@ -2627,13 +2630,18 @@ export function createRouter(deps: RouterDeps) { !(hasActiveComputerControl(bot.computer) && bot.computer.controlBotId === bot.id), ); return { - url: addScreenProxyCapability(viewUrl, deps.env.screenProxySecret, deps.env.webOrigin, { - botId: bot.id, - computerId: computer.id, - botGeneration: bot.screenGeneration, - computerGeneration: computer.screenGeneration, - controlLeaseId: computer.controlLeaseId, - }), + url: addScreenProxyCapability( + viewUrl, + deps.env.screenProxySecret, + webOriginFor(context), + { + botId: bot.id, + computerId: computer.id, + botGeneration: bot.screenGeneration, + computerGeneration: computer.screenGeneration, + controlLeaseId: computer.controlLeaseId, + }, + ), }; }), heartbeat: authed.computer.heartbeat.handler(async ({ context, input }) => { @@ -3635,7 +3643,10 @@ export function createRouter(deps: RouterDeps) { oauth: { begin: authed.mcp.oauth.begin.handler(async ({ context, input }) => { try { - const expectedRedirect = new URL("/mcp/oauth/callback", deps.env.webOrigin).toString(); + const expectedRedirect = new URL( + "/mcp/oauth/callback", + webOriginFor(context), + ).toString(); if (new URL(input.redirectUri).toString() !== expectedRedirect) { throw new Error("MCP OAuth redirect URI is not allowed"); } @@ -3730,7 +3741,7 @@ export function createRouter(deps: RouterDeps) { return { canConfigure, needsSetup: canConfigure && !providers.some((provider) => provider.configured), - webUrl: new URL("/integrations/setup", deps.env.webOrigin).toString(), + webUrl: new URL("/integrations/setup", webOriginFor(context)).toString(), providers, }; }), @@ -3854,7 +3865,7 @@ export function createRouter(deps: RouterDeps) { }); try { const auth = await connector.begin( - { provider: input.provider, redirectUrl: `${deps.env.webOrigin}/app` }, + { provider: input.provider, redirectUrl: `${webOriginFor(context)}/app` }, connectionContext(context.actor, "connections.begin", context.signal), ); // Re-take the provider lock and only advance still-pending rows so a diff --git a/apps/api/src/web-origin.test.ts b/apps/api/src/web-origin.test.ts new file mode 100644 index 0000000..943d75b --- /dev/null +++ b/apps/api/src/web-origin.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { requestWebOrigin } from "./web-origin.js"; + +const configured = "http://127.0.0.1:5173"; + +describe("requestWebOrigin", () => { + it("follows a browser on loopback or a private network", () => { + expect(requestWebOrigin("http://192.168.0.20:5173", configured)).toBe( + "http://192.168.0.20:5173", + ); + expect(requestWebOrigin("http://10.0.0.5:5173", configured)).toBe("http://10.0.0.5:5173"); + expect(requestWebOrigin("http://mac.local:5173", configured)).toBe("http://mac.local:5173"); + expect(requestWebOrigin("http://localhost:5173", configured)).toBe("http://localhost:5173"); + }); + + it("keeps WEB_ORIGIN for public hosts, other schemes, and missing origins", () => { + expect(requestWebOrigin("https://attacker.example", configured)).toBe(configured); + expect(requestWebOrigin("rakazo://app", configured)).toBe(configured); + expect(requestWebOrigin("null", configured)).toBe(configured); + expect(requestWebOrigin(undefined, configured)).toBe(configured); + }); + + it("accepts the configured origin even when it is public", () => { + expect(requestWebOrigin("https://evoo.example", "https://evoo.example")).toBe( + "https://evoo.example", + ); + }); +}); diff --git a/apps/api/src/web-origin.ts b/apps/api/src/web-origin.ts new file mode 100644 index 0000000..3a210fa --- /dev/null +++ b/apps/api/src/web-origin.ts @@ -0,0 +1,30 @@ +/** Hosts the API already trusts as browser origins: loopback and private networks. */ +export function isPrivateOrLocalHost(host: string): boolean { + if (isLoopbackHost(host)) return true; + if (host.endsWith(".local")) return true; + if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(host)) return true; + if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true; + if (/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(host)) return true; + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}$/.test(host)) return true; + return false; +} + +export function isLoopbackHost(host: string): boolean { + return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]"; +} + +/** The web origin that browser-facing URLs (OAuth returns, screen links) should + * point at for this request. A browser opened over a loopback or private-network + * address gets its own origin back so a phone on the LAN is not sent to + * 127.0.0.1; any other caller gets the configured WEB_ORIGIN. */ +export function requestWebOrigin(origin: string | undefined, webOrigin: string): string { + if (!origin) return webOrigin; + try { + const url = new URL(origin); + if (url.protocol !== "http:" && url.protocol !== "https:") return webOrigin; + if (url.origin === new URL(webOrigin).origin) return url.origin; + return isPrivateOrLocalHost(url.hostname) ? url.origin : webOrigin; + } catch { + return webOrigin; + } +} diff --git a/apps/web/src/components/integrations/IntegrationSetup.tsx b/apps/web/src/components/integrations/IntegrationSetup.tsx index ab7e95f..b0a2e40 100644 --- a/apps/web/src/components/integrations/IntegrationSetup.tsx +++ b/apps/web/src/components/integrations/IntegrationSetup.tsx @@ -107,7 +107,8 @@ export function IntegrationSetup({ const server = existing ?? (await rpc.mcp.servers.create({ - slug: `integration-${crypto.randomUUID().slice(0, 8)}`, + // randomUUID is missing outside secure contexts (a phone on the LAN over http). + slug: `integration-${Math.random().toString(36).slice(2, 10)}`, name, transport: "streamable_http", endpoint: url,