Require exact hosted MCP origins (#515)

* Require exact hosted MCP origins

* Clarify exact MCP origin policy
This commit is contained in:
Ben Senescu 2026-08-21 19:01:54 -04:00 committed by Ben Senescu
parent e5e961bf48
commit 4ba2d6c175
3 changed files with 133 additions and 8 deletions

View File

@ -1,7 +1,35 @@
import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server"; import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server";
import { McpServer } from "@modelcontextprotocol/server"; import { McpServer } from "@modelcontextprotocol/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { z } from "zod"; import { z } from "zod";
import { createWorkersOAuthMcpProps } from "@/server/mcp/context";
import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport";
vi.mock("@/lib/auth", () => ({
getHostedBaseUrl: () => "https://open-seo.test",
}));
vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({
resolveCloudflareAccessContext: vi.fn(),
}));
vi.mock("@/middleware/ensure-user/delegated", () => ({
resolveLocalNoAuthContext: vi.fn(),
}));
vi.mock("@/server/mcp/server", async () => {
const { McpServer: ActualMcpServer } =
await import("@modelcontextprotocol/server");
return {
createOpenSeoMcpServer: () => {
const server = new ActualMcpServer({ name: "test", version: "1.0.0" });
server.registerTool("ping", {}, () => ({
content: [{ type: "text" as const, text: "pong" }],
}));
return server;
},
};
});
const ctx: ExecutionContext = { const ctx: ExecutionContext = {
waitUntil() {}, waitUntil() {},
@ -136,4 +164,50 @@ describe("Agents SDK v2 MCP transport", () => {
expect(surfMindResponse.status).toBe(200); expect(surfMindResponse.status).toBe(200);
expect(unrelatedOriginResponse.status).toBe(403); expect(unrelatedOriginResponse.status).toBe(403);
}); });
it("enforces exact hosted origins around the real SDK handler", async () => {
const props = createWorkersOAuthMcpProps({
userId: "user-1",
userEmail: "user@example.com",
organizationId: "org-1",
baseUrl: "https://open-seo.test",
clientId: "client-1",
scopes: ["mcp"],
});
const modernToolsList = {
jsonrpc: "2.0",
id: 1,
method: "tools/list",
params: {
_meta: {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
},
},
};
const call = (origin?: string) =>
handleAuthenticatedOpenSeoMcpRequest(
request("POST", modernToolsList, {
"Mcp-Method": "tools/list",
...(origin ? { Origin: origin } : {}),
}),
props,
{},
{ ...ctx, props },
);
await expect(
call("chrome-extension://pghallcbnfabbgfijhbcldaapmgidnaa"),
).resolves.toMatchObject({ status: 200 });
await expect(call("https://open-seo.test")).resolves.toMatchObject({
status: 200,
});
await expect(call()).resolves.toMatchObject({ status: 200 });
await expect(
call("https://pghallcbnfabbgfijhbcldaapmgidnaa"),
).resolves.toMatchObject({ status: 403 });
await expect(
call("chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
).resolves.toMatchObject({ status: 403 });
});
}); });

View File

@ -84,12 +84,13 @@ function createMcpRequest(headers?: Record<string, string>) {
// The modern (2026-07-28) era is selected by the per-request `_meta` envelope // The modern (2026-07-28) era is selected by the per-request `_meta` envelope
// claim; without it every POST classifies as legacy traffic. // claim; without it every POST classifies as legacy traffic.
function createModernMcpRequest() { function createModernMcpRequest(headers?: Record<string, string>) {
return new Request("https://open-seo.test/mcp", { return new Request("https://open-seo.test/mcp", {
method: "POST", method: "POST",
headers: { headers: {
Accept: "application/json, text/event-stream", Accept: "application/json, text/event-stream",
"Content-Type": "application/json", "Content-Type": "application/json",
...headers,
}, },
body: JSON.stringify({ body: JSON.stringify({
jsonrpc: "2.0", jsonrpc: "2.0",
@ -244,6 +245,22 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => {
expect(selfHostedAuthMocks.createOpenSeoMcpServer).not.toHaveBeenCalled(); expect(selfHostedAuthMocks.createOpenSeoMcpServer).not.toHaveBeenCalled();
}); });
it("accepts a modern request from the exact SurfMind extension origin", async () => {
const props = hostedProps();
const response = await handleAuthenticatedOpenSeoMcpRequest(
createModernMcpRequest({
Origin: "chrome-extension://pghallcbnfabbgfijhbcldaapmgidnaa",
}),
props,
{},
{ ...ctx, props },
);
expect(response.status).toBe(202);
expect(await response.json()).toEqual({ handledBy: "modern" });
});
it("rejects a legacy request from a disallowed Origin", async () => { it("rejects a legacy request from a disallowed Origin", async () => {
const props = hostedProps(); const props = hostedProps();
@ -276,6 +293,28 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => {
); );
}); });
it.each([
[
"the SurfMind hostname over HTTPS",
"https://pghallcbnfabbgfijhbcldaapmgidnaa",
],
[
"another Chrome extension",
"chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
],
])("rejects a request from %s", async (_label, origin) => {
const props = hostedProps();
const response = await handleAuthenticatedOpenSeoMcpRequest(
createMcpRequest({ Origin: origin }),
props,
{},
{ ...ctx, props },
);
expect(response.status).toBe(403);
});
it("rejects provider props missing the OAuth client identity", async () => { it("rejects provider props missing the OAuth client identity", async () => {
// Hosted tokens always carry clientId/scopes; a token without them must // Hosted tokens always carry clientId/scopes; a token without them must
// fail closed rather than skip scope enforcement. // fail closed rather than skip scope enforcement.

View File

@ -33,6 +33,7 @@ const MCP_CORS_HEADERS = {
} as const; } as const;
const SURFMIND_CHROME_EXTENSION_HOSTNAME = "pghallcbnfabbgfijhbcldaapmgidnaa"; const SURFMIND_CHROME_EXTENSION_HOSTNAME = "pghallcbnfabbgfijhbcldaapmgidnaa";
const SURFMIND_CHROME_EXTENSION_ORIGIN = `chrome-extension://${SURFMIND_CHROME_EXTENSION_HOSTNAME}`;
function withMcpCors(response: Response) { function withMcpCors(response: Response) {
const headers = new Headers(response.headers); const headers = new Headers(response.headers);
@ -113,11 +114,12 @@ async function handleLegacyJsonRequest(request: Request, props: McpProps) {
} }
} }
// Hosted pins browser Origins to the configured base URL. Self-hosted leaves // Hosted applies exact-origin validation before passing the corresponding
// the option unset so the handler's localhost-class default applies — an // hostname allowlist to the SDK as defense in depth. Self-hosted leaves the
// allowlist derived from the request's own Host would accept a DNS-rebinding // option unset so the handler's localhost-class default applies — an allowlist
// page trivially. Non-browser MCP clients send no Origin and are unaffected // derived from the request's own Host would accept a DNS-rebinding page
// either way. // trivially. Non-browser MCP clients send no Origin and are unaffected either
// way.
function createRequestHandler( function createRequestHandler(
props: McpProps, props: McpProps,
allowedOriginHostnames?: string[], allowedOriginHostnames?: string[],
@ -158,8 +160,18 @@ export async function handleAuthenticatedOpenSeoMcpRequest(
return new Response("MCP scope required", { status: 403 }); return new Response("MCP scope required", { status: 403 });
} }
const hostedUrl = new URL(getHostedBaseUrl());
const origin = request.headers.get("Origin");
if (
origin &&
origin !== hostedUrl.origin &&
origin !== SURFMIND_CHROME_EXTENSION_ORIGIN
) {
return withMcpCors(new Response("Invalid Origin", { status: 403 }));
}
return createRequestHandler(result.data, [ return createRequestHandler(result.data, [
new URL(getHostedBaseUrl()).hostname, hostedUrl.hostname,
SURFMIND_CHROME_EXTENSION_HOSTNAME, SURFMIND_CHROME_EXTENSION_HOSTNAME,
])(request, env, ctx); ])(request, env, ctx);
} }