diff --git a/src/server/http/serveStreamableHttp.test.ts b/src/server/http/serveStreamableHttp.test.ts index 95b6be970d..accba0c190 100644 --- a/src/server/http/serveStreamableHttp.test.ts +++ b/src/server/http/serveStreamableHttp.test.ts @@ -791,14 +791,16 @@ it("closes the connection when a route read only part of the body and keeps it a } }); -it("does not let a zero-quality or quoted Accept range qualify a session takeover", async () => { +it("does not let a zero-quality, quoted or SDK-refused Accept qualify a session takeover", async () => { const { served } = await start(); const { client } = await connect(served.url); const url = new URL(served.url); // The second header holds one range: the comma and `text/event-stream` sit inside a quoted parameter. + // The third is valid HTTP, but the SDK matches media types with case and would answer 406. for (const accept of [ "application/json;q=0, text/event-stream", 'application/json;note=", text/event-stream;x=1"', + "Application/JSON, Text/Event-Stream", ]) { const refused = await rawHttpRequest(url, { method: "POST", diff --git a/src/server/http/serveStreamableHttp.ts b/src/server/http/serveStreamableHttp.ts index d4e311dc84..432c4ea597 100644 --- a/src/server/http/serveStreamableHttp.ts +++ b/src/server/http/serveStreamableHttp.ts @@ -1,6 +1,7 @@ import { hostHeaderValidationResponse, isInitializeRequest, + isJsonContentType, localhostAllowedHostnames, originValidationResponse, readRequestBody, @@ -145,17 +146,22 @@ const quality = (range: string): number => { }; /** The headers the SDK requires of a streamable POST; a request without them is left to the transport's refusal. */ const hasStreamablePostHeaders = (request: Request): boolean => { + const accept = request.headers.get("accept") ?? ""; // Exact media ranges with a positive weight: `application/json-patch+json`, `text/event-streaming` // and `application/json;q=0` do not count. const accepted = new Set( - splitUnquoted(request.headers.get("accept") ?? "", ",") + splitUnquoted(accept, ",") .filter((range) => quality(range) > 0) .map(mediaType), ); + // The SDK's own checks too: its Accept test is a case-sensitive substring match, so a header it + // would refuse with 406 (such as `Application/JSON`) must not close the live session first. return ( accepted.has("application/json") && accepted.has("text/event-stream") && - mediaType(request.headers.get("content-type") ?? "") === "application/json" + accept.includes("application/json") && + accept.includes("text/event-stream") && + isJsonContentType(request.headers.get("content-type")) ); };