Require exact hosted MCP origins (#515)
* Require exact hosted MCP origins * Clarify exact MCP origin policy
This commit is contained in:
parent
e5e961bf48
commit
4ba2d6c175
@ -1,7 +1,35 @@
|
||||
import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server";
|
||||
import { McpServer } from "@modelcontextprotocol/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
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 = {
|
||||
waitUntil() {},
|
||||
@ -136,4 +164,50 @@ describe("Agents SDK v2 MCP transport", () => {
|
||||
expect(surfMindResponse.status).toBe(200);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -84,12 +84,13 @@ function createMcpRequest(headers?: Record<string, string>) {
|
||||
|
||||
// The modern (2026-07-28) era is selected by the per-request `_meta` envelope
|
||||
// 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", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json, text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
@ -244,6 +245,22 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
// Hosted tokens always carry clientId/scopes; a token without them must
|
||||
// fail closed rather than skip scope enforcement.
|
||||
|
||||
@ -33,6 +33,7 @@ const MCP_CORS_HEADERS = {
|
||||
} as const;
|
||||
|
||||
const SURFMIND_CHROME_EXTENSION_HOSTNAME = "pghallcbnfabbgfijhbcldaapmgidnaa";
|
||||
const SURFMIND_CHROME_EXTENSION_ORIGIN = `chrome-extension://${SURFMIND_CHROME_EXTENSION_HOSTNAME}`;
|
||||
|
||||
function withMcpCors(response: Response) {
|
||||
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
|
||||
// the option unset so the handler's localhost-class default applies — an
|
||||
// allowlist derived from the request's own Host would accept a DNS-rebinding
|
||||
// page trivially. Non-browser MCP clients send no Origin and are unaffected
|
||||
// either way.
|
||||
// Hosted applies exact-origin validation before passing the corresponding
|
||||
// hostname allowlist to the SDK as defense in depth. Self-hosted leaves the
|
||||
// option unset so the handler's localhost-class default applies — an allowlist
|
||||
// derived from the request's own Host would accept a DNS-rebinding page
|
||||
// trivially. Non-browser MCP clients send no Origin and are unaffected either
|
||||
// way.
|
||||
function createRequestHandler(
|
||||
props: McpProps,
|
||||
allowedOriginHostnames?: string[],
|
||||
@ -158,8 +160,18 @@ export async function handleAuthenticatedOpenSeoMcpRequest(
|
||||
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, [
|
||||
new URL(getHostedBaseUrl()).hostname,
|
||||
hostedUrl.hostname,
|
||||
SURFMIND_CHROME_EXTENSION_HOSTNAME,
|
||||
])(request, env, ctx);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user