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 <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-05 00:57:27 +02:00 committed by GitHub
parent 7db3a56fe8
commit fb5165bb26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 153 additions and 28 deletions

View File

@ -118,6 +118,7 @@ import {
TEAM_CHAT_STARTUP_SHUTDOWN_MS, TEAM_CHAT_STARTUP_SHUTDOWN_MS,
} from "./team-chat-startup.js"; } from "./team-chat-startup.js";
import { mountVoiceHttpRoutes } from "./voice.js"; import { mountVoiceHttpRoutes } from "./voice.js";
import { isLoopbackHost, isPrivateOrLocalHost, requestWebOrigin } from "./web-origin.js";
import { mountWebhookHttpRoutes } from "./webhook.js"; import { mountWebhookHttpRoutes } from "./webhook.js";
export interface AppHandles { export interface AppHandles {
@ -590,7 +591,12 @@ export async function createApp(
} }
const { matched, response } = await rpc.handle(c.req.raw, { const { matched, response } = await rpc.handle(c.req.raw, {
prefix: "/rpc", 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); if (matched) return c.newResponse(response.body, response);
await next(); 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) { function sessionHeaders(request: Request) {
const headers = new Headers(request.headers); const headers = new Headers(request.headers);
const authz = headers.get("authorization"); const authz = headers.get("authorization");

View File

@ -627,7 +627,11 @@ describe("computer screen url", () => {
controlRunId: null, controlRunId: null,
}; };
const callScreenUrl = async (connectScreen: () => Promise<unknown>, updateMany = vi.fn()) => { const callScreenUrl = async (
connectScreen: () => Promise<unknown>,
updateMany = vi.fn(),
webOrigin?: string,
) => {
const prisma = { const prisma = {
bot: { bot: {
findFirst: vi.fn().mockResolvedValue({ findFirst: vi.fn().mockResolvedValue({
@ -660,7 +664,7 @@ describe("computer screen url", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ json: { botId: "bot-1" } }), body: JSON.stringify({ json: { botId: "bot-1" } }),
}), }),
{ prefix: "/rpc", context: { actor } }, { prefix: "/rpc", context: { actor, webOrigin } },
); );
return { response, updateMany }; 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 () => { it("returns desktop provider screen URLs without sealing them", async () => {
const { response } = await callScreenUrl(async () => ({ const { response } = await callScreenUrl(async () => ({
url: "desktop://screen/computer-1", url: "desktop://screen/computer-1",
@ -977,3 +991,52 @@ describe("interrupted computer reservation release", () => {
expect(order).toEqual(["operation", "computer"]); 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();
});
});

View File

@ -574,8 +574,11 @@ export function createRouter(deps: RouterDeps) {
actor: Actor | null; actor: Actor | null;
signal?: AbortSignal; signal?: AbortSignal;
userAgent?: string; userAgent?: string;
/** Browser origin for URLs handed back to this request; see requestWebOrigin. */
webOrigin?: string;
}>(); }>();
const repos = createRepos(deps.prisma); const repos = createRepos(deps.prisma);
const webOriginFor = (context: { webOrigin?: string }) => context.webOrigin ?? deps.env.webOrigin;
const onboardingDeps = { const onboardingDeps = {
prisma: deps.prisma, prisma: deps.prisma,
events: deps.events, events: deps.events,
@ -2627,13 +2630,18 @@ export function createRouter(deps: RouterDeps) {
!(hasActiveComputerControl(bot.computer) && bot.computer.controlBotId === bot.id), !(hasActiveComputerControl(bot.computer) && bot.computer.controlBotId === bot.id),
); );
return { return {
url: addScreenProxyCapability(viewUrl, deps.env.screenProxySecret, deps.env.webOrigin, { url: addScreenProxyCapability(
botId: bot.id, viewUrl,
computerId: computer.id, deps.env.screenProxySecret,
botGeneration: bot.screenGeneration, webOriginFor(context),
computerGeneration: computer.screenGeneration, {
controlLeaseId: computer.controlLeaseId, botId: bot.id,
}), computerId: computer.id,
botGeneration: bot.screenGeneration,
computerGeneration: computer.screenGeneration,
controlLeaseId: computer.controlLeaseId,
},
),
}; };
}), }),
heartbeat: authed.computer.heartbeat.handler(async ({ context, input }) => { heartbeat: authed.computer.heartbeat.handler(async ({ context, input }) => {
@ -3635,7 +3643,10 @@ export function createRouter(deps: RouterDeps) {
oauth: { oauth: {
begin: authed.mcp.oauth.begin.handler(async ({ context, input }) => { begin: authed.mcp.oauth.begin.handler(async ({ context, input }) => {
try { 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) { if (new URL(input.redirectUri).toString() !== expectedRedirect) {
throw new Error("MCP OAuth redirect URI is not allowed"); throw new Error("MCP OAuth redirect URI is not allowed");
} }
@ -3730,7 +3741,7 @@ export function createRouter(deps: RouterDeps) {
return { return {
canConfigure, canConfigure,
needsSetup: canConfigure && !providers.some((provider) => provider.configured), 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, providers,
}; };
}), }),
@ -3854,7 +3865,7 @@ export function createRouter(deps: RouterDeps) {
}); });
try { try {
const auth = await connector.begin( 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), connectionContext(context.actor, "connections.begin", context.signal),
); );
// Re-take the provider lock and only advance still-pending rows so a // Re-take the provider lock and only advance still-pending rows so a

View File

@ -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",
);
});
});

View File

@ -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;
}
}

View File

@ -107,7 +107,8 @@ export function IntegrationSetup({
const server = const server =
existing ?? existing ??
(await rpc.mcp.servers.create({ (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, name,
transport: "streamable_http", transport: "streamable_http",
endpoint: url, endpoint: url,