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 { 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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user