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,
|
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");
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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 =
|
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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user