fix(server): validate Content-Type by parsed media type instead of substring match (#2441)
This commit is contained in:
parent
7015d2129b
commit
561c6d8345
3 changed files with 452 additions and 20 deletions
|
|
@ -41,6 +41,8 @@ import {
|
|||
CLIENT_CAPABILITIES_META_KEY,
|
||||
CLIENT_INFO_META_KEY,
|
||||
httpStatusForErrorCode,
|
||||
isJsonContentType,
|
||||
mediaTypeEssence,
|
||||
missingClientCapabilities,
|
||||
MissingRequiredClientCapabilityError,
|
||||
modernOnlyStrictRejection,
|
||||
|
|
@ -330,7 +332,7 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er
|
|||
...(options?.authInfo !== undefined && { authInfo: options.authInfo }),
|
||||
...(options?.parsedBody !== undefined && { parsedBody: options.parsedBody })
|
||||
});
|
||||
if (response.body === null || !(response.headers.get('content-type') ?? '').includes('text/event-stream')) {
|
||||
if (response.body === null || mediaTypeEssence(response.headers.get('content-type')) !== 'text/event-stream') {
|
||||
// Non-streaming exchange (a buffered JSON body or a body-less
|
||||
// ack): the response is complete, release the pair now.
|
||||
teardown();
|
||||
|
|
@ -483,7 +485,11 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno
|
|||
* This is the entry's own classification step exported as a predicate — it
|
||||
* runs exactly the code `createMcpHandler` runs to make the routing decision,
|
||||
* not a re-implementation — so a hand-wired composition that branches on it
|
||||
* can never disagree with the entry. Use it to keep an existing legacy
|
||||
* can never disagree with the entry. It is classification only: hand-wired
|
||||
* compositions must validate Content-Type themselves (415 for POSTs whose
|
||||
* media type is not `application/json`, via {@linkcode isJsonContentType})
|
||||
* before dispatching either leg — routing the legacy leg into the SDK
|
||||
* transports gets their built-in check, but a custom modern leg has none. Use it to keep an existing legacy
|
||||
* deployment (for example a sessionful streamable HTTP wiring) serving 2025
|
||||
* traffic next to a strict modern endpoint, now that the entry has no
|
||||
* handler-valued `legacy` option:
|
||||
|
|
@ -574,7 +580,10 @@ export async function isLegacyRequest(request: Request, parsedBody?: unknown): P
|
|||
* pattern. Power users composing transport-neutral routing can also use the
|
||||
* exported building blocks directly: {@linkcode classifyInboundRequest} for
|
||||
* the era decision and `PerRequestHTTPServerTransport` for single-exchange
|
||||
* serving.
|
||||
* serving — such compositions must reject POSTs whose Content-Type media type
|
||||
* is not `application/json` (415) before parsing the body, using
|
||||
* {@linkcode isJsonContentType}; neither building block performs this
|
||||
* validation itself.
|
||||
*
|
||||
* The entry performs no token verification: `authInfo` given to `fetch` is
|
||||
* passed through to handlers and the factory as-is and is never derived from
|
||||
|
|
@ -817,6 +826,18 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
|
|||
|
||||
async function handle(request: Request, requestOptions?: McpHandlerRequestOptions): Promise<Response> {
|
||||
const authInfo = requestOptions?.authInfo;
|
||||
|
||||
// Content-Type check, answered before the body is read (parsed media
|
||||
// type — see isJsonContentType). Load-bearing for the modern leg,
|
||||
// whose ladder does not inspect Content-Type; the legacy transport
|
||||
// keeps its own check for hand-wired use, so via this entry a
|
||||
// doubly-invalid request answers 415 here before the transport's 406
|
||||
// Accept check would.
|
||||
if (request.method.toUpperCase() === 'POST' && !isJsonContentType(request.headers.get('content-type'))) {
|
||||
reportError(new Error('Unsupported Media Type: Content-Type must be application/json'));
|
||||
return jsonRpcErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json');
|
||||
}
|
||||
|
||||
const classified = await classifyEntryRequest(request, requestOptions?.parsedBody);
|
||||
|
||||
if (classified.step === 'unreadable-body') {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@
|
|||
* `createMcpHandler`) inherit the spec's per-error HTTP mandate with it: a
|
||||
* terminal `MissingRequiredClientCapabilityError` (-32021) MUST answer 400,
|
||||
* which this transport applies whenever the response is still uncommitted.
|
||||
* A custom entry must also reject POSTs whose Content-Type media type is not
|
||||
* `application/json` (415) before parsing the body — use `isJsonContentType`
|
||||
* from the package root; this transport never sees the HTTP headers, so it
|
||||
* cannot perform that validation itself.
|
||||
*/
|
||||
import type {
|
||||
AuthInfo,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport }
|
|||
import {
|
||||
DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
|
||||
isInitializeRequest,
|
||||
isJsonContentType,
|
||||
isJSONRPCErrorResponse,
|
||||
isJSONRPCRequest,
|
||||
isJSONRPCResultResponse,
|
||||
|
|
@ -680,6 +681,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
|
|||
// Validate the Accept header
|
||||
const acceptHeader = req.headers.get('accept');
|
||||
// The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types.
|
||||
// Accept is a comma-separated list, so a substring check is the intended semantics here (unlike Content-Type below).
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (!acceptHeader?.includes('application/json') || !acceptHeader.includes('text/event-stream')) {
|
||||
this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream'));
|
||||
return this.createJsonErrorResponse(
|
||||
|
|
@ -689,8 +692,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
|
|||
);
|
||||
}
|
||||
|
||||
// Parsed media type, never a substring match — see
|
||||
// isJsonContentType. This check stays here for hand-wired
|
||||
// transports; via createMcpHandler the entry's own check answers
|
||||
// first.
|
||||
const ct = req.headers.get('content-type');
|
||||
if (!ct || !ct.includes('application/json')) {
|
||||
if (!isJsonContentType(ct)) {
|
||||
this.onerror?.(new Error('Unsupported Media Type: Content-Type must be application/json'));
|
||||
return this.createJsonErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue