fix: narrow the exemption per transport (#2319 round 18)
Round 17 widened exemptMcpEndpoint to the origin for everything, which exempted same-origin OAuth work as well as MCP traffic — on the very 401/discovery path #2319 exists to bound. The rule is now as narrow as each transport allows. Streamable HTTP sends every MCP request to the configured URL, so origin + pathname is exact there and same-origin OAuth stays bounded. Legacy SSE negotiates its message endpoint inside the stream, where we never see it, so the origin rule is the narrowest available — and it is exact rather than approximate, since the SDK enforces that the message endpoint shares the stream URL's origin. The residual gap is therefore confined to legacy SSE against a server hosting its own OAuth endpoints, and it is stated in the docblock and pinned by a test rather than left implicit. Neither alternative was reachable: the SDK takes a single fetch for all network requests, so there is no separate auth seam to route through, and the negotiated SSE endpoint is parsed inside the SDK's own stream handler. Fails open on every uncertainty, including not knowing the transport — a path rule applied to what turns out to be SSE severs real MCP traffic, so an absent or throwing hint falls back to the origin rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
5a1c157a79
commit
42339ff4a4
1 changed files with 109 additions and 34 deletions
|
|
@ -506,6 +506,58 @@ describe("withOAuthRequestTimeout", () => {
|
|||
expect(isExempt(`${SERVER}#frag`)).toBe(true);
|
||||
});
|
||||
|
||||
/** Streamable HTTP: every MCP request goes to the configured URL. */
|
||||
const streamable = (serverUrl: string) =>
|
||||
exemptMcpEndpoint(
|
||||
() => serverUrl,
|
||||
() => false,
|
||||
);
|
||||
/** Legacy SSE: the message endpoint arrives inside the stream. */
|
||||
const sse = (serverUrl: string) =>
|
||||
exemptMcpEndpoint(
|
||||
() => serverUrl,
|
||||
() => true,
|
||||
);
|
||||
|
||||
it("bounds same-origin OAuth work on a Streamable HTTP connection", () => {
|
||||
// The narrow rule, available because the endpoint is the configured URL.
|
||||
const isExempt = streamable(SERVER);
|
||||
|
||||
expect(isExempt(SERVER)).toBe(true);
|
||||
expect(
|
||||
isExempt(
|
||||
"https://srv.example.com/.well-known/oauth-protected-resource/mcp",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isExempt("https://srv.example.com/token")).toBe(false);
|
||||
});
|
||||
|
||||
it("widens to the origin on legacy SSE, whose message path is unknowable", () => {
|
||||
// The cost of the wider rule, asserted rather than implied: same-origin
|
||||
// OAuth is exempt here, and falls back to the SDK's per-request timeout.
|
||||
const isExempt = sse("https://srv.example.com/sse");
|
||||
|
||||
expect(
|
||||
isExempt("https://srv.example.com/.well-known/openid-configuration"),
|
||||
).toBe(true);
|
||||
expect(isExempt("https://as.example.com/token")).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the origin rule when the transport is unknown", () => {
|
||||
// Fails open on this uncertainty like every other: a path rule applied to
|
||||
// a connection that turns out to be SSE severs real MCP traffic.
|
||||
const noHint = exemptMcpEndpoint(() => SERVER);
|
||||
const throws = exemptMcpEndpoint(
|
||||
() => SERVER,
|
||||
() => {
|
||||
throw new Error("not yet connected");
|
||||
},
|
||||
);
|
||||
|
||||
expect(noHint("https://srv.example.com/token")).toBe(true);
|
||||
expect(throws("https://srv.example.com/token")).toBe(true);
|
||||
});
|
||||
|
||||
it("exempts legacy SSE's separate message endpoint", () => {
|
||||
// The case a path rule gets wrong: `SSEClientTransport` opens the
|
||||
// configured URL and is handed a *different* pathname to POST every
|
||||
|
|
@ -533,21 +585,6 @@ describe("withOAuthRequestTimeout", () => {
|
|||
expect(isExempt("https://srv.example.com:8443/token")).toBe(false);
|
||||
});
|
||||
|
||||
it("exempts a same-origin OAuth endpoint, which is the cost of the rule", () => {
|
||||
// Stated as a test rather than left implicit: protected-resource metadata
|
||||
// lives on the resource's own origin, so it is exempt here. The OAuth
|
||||
// chain still bounds the Inspector's own requests to it; only the SDK's
|
||||
// transport-internal OAuth against a same-origin server falls back to the
|
||||
// SDK's per-request timeout.
|
||||
const isExempt = exemptMcpEndpoint(() => SERVER);
|
||||
|
||||
expect(
|
||||
isExempt(
|
||||
"https://srv.example.com/.well-known/oauth-protected-resource/mcp",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails open when the server URL is unknown or unparseable", () => {
|
||||
// The two errors are not symmetric: bounding what should not be bounded
|
||||
// severs a long-running tool call, while failing to bound leaves the
|
||||
|
|
|
|||
Loading…
Reference in a new issue