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:
parent
7db3a56fe8
commit
fb5165bb26
@ -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");
|
||||
|
||||
@ -627,7 +627,11 @@ describe("computer screen url", () => {
|
||||
controlRunId: null,
|
||||
};
|
||||
|
||||
const callScreenUrl = async (connectScreen: () => Promise<unknown>, updateMany = vi.fn()) => {
|
||||
const callScreenUrl = async (
|
||||
connectScreen: () => Promise<unknown>,
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
28
apps/api/src/web-origin.test.ts
Normal file
28
apps/api/src/web-origin.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
30
apps/api/src/web-origin.ts
Normal file
30
apps/api/src/web-origin.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user