-
Notifications
You must be signed in to change notification settings - Fork 2k
fix(server): validate Content-Type by parsed media type instead of substring match #2441
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
felixweinberger
merged 7 commits into
main
from
fweinberger/content-type-media-type-validation
Jul 6, 2026
+452
−20
Merged
Changes from 6 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
6e802b5
Validate Content-Type by parsed media type instead of substring match
felixweinberger 9a07b42
Merge remote-tracking branch 'origin/main' into fweinberger/content-t…
felixweinberger 8193692
Fix import ordering in client auth
felixweinberger 80a1f8a
Avoid unresolved docs link in isJsonContentType comment
felixweinberger c3e32b8
Reject joined duplicate Content-Type headers uniformly
felixweinberger d743152
Merge branch 'main' into fweinberger/content-type-media-type-validation
felixweinberger b2546a1
Merge branch 'main' into fweinberger/content-type-media-type-validation
felixweinberger File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| --- | ||
| '@modelcontextprotocol/server': patch | ||
| '@modelcontextprotocol/client': patch | ||
| '@modelcontextprotocol/hono': patch | ||
| '@modelcontextprotocol/node': patch | ||
| --- | ||
|
|
||
| POSTs whose `Content-Type` media type is not `application/json` are now | ||
| rejected with `415 Unsupported Media Type`; the header is parsed instead of | ||
| substring-matched. Previously any value merely containing the substring | ||
| passed the check (for example `text/plain; a=application/json`), case | ||
| variants were wrongly rejected, and the 2026-07-28 entry did not inspect | ||
| `Content-Type` at all — requests with a missing or non-JSON header that used | ||
| to be served on that path now also answer 415. Values with parameters | ||
| (`application/json; charset=utf-8`, including malformed parameter sections | ||
| like `application/json;`) continue to work. SDK clients always send the | ||
| correct header and are unaffected. | ||
|
|
||
| The new `isJsonContentType(header)` helper is exported for transport and | ||
| framework-adapter authors — custom entries composing the exported building | ||
| blocks (`classifyInboundRequest`, `PerRequestHTTPServerTransport`) must apply | ||
| it themselves. The hono adapter's JSON body pre-parse and the client's | ||
| response dispatch now use the same parsed-media-type comparison. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import contentType from 'content-type'; | ||
|
|
||
| /** | ||
| * Extracts the media type (the lowercased `type/subtype` pair, without | ||
| * parameters) from a raw `Content-Type` header value, or `undefined` when the | ||
| * header is missing or empty. | ||
| * | ||
| * Content-Type comparisons must use the parsed media type, never a substring | ||
| * search of the raw header: a value like `text/plain; a=application/json` | ||
| * contains the substring `application/json` but its media type is | ||
| * `text/plain`, and case variants or parameters make naive string comparison | ||
| * wrong in both directions. | ||
| * | ||
| * "Essence" is the WHATWG MIME Sniffing standard's term for the bare | ||
| * `type/subtype` pair (https://mimesniff.spec.whatwg.org/#mime-type-essence); | ||
| * the Fetch standard's request classification is defined against it | ||
| * (https://fetch.spec.whatwg.org/#cors-safelisted-request-header). | ||
| * | ||
| * Parsing is RFC 9110 (`content-type` package) first. When the parameter | ||
| * section is malformed (`application/json;`, `application/json; charset=`), | ||
| * browsers and most HTTP stacks still derive the media type from the segment | ||
| * before the first `;` — the fallback matches that widely-implemented | ||
| * behavior, so a header whose media type is unambiguous is not rejected for | ||
| * a sloppy parameter section. | ||
| */ | ||
| export function mediaTypeEssence(header: string | null | undefined): string | undefined { | ||
| if (!header) { | ||
| return undefined; | ||
| } | ||
| try { | ||
| return contentType.parse(header).type; | ||
| } catch { | ||
| const essence = (header.split(';', 1)[0] ?? '').trim().toLowerCase(); | ||
| // A comma in the parameter tail of an unparseable value indicates | ||
| // joined duplicate headers — ambiguous, so no essence at all (keeps | ||
| // duplicate-header handling uniform whether or not the first copy | ||
| // carries parameters). | ||
| if (essence === '' || header.slice(essence.length).includes(',')) { | ||
| return undefined; | ||
| } | ||
| return essence; | ||
| } | ||
|
claude[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
| * Whether a raw `Content-Type` header value denotes `application/json`. | ||
| * Parameters (for example `charset=utf-8`) are allowed and ignored; malformed | ||
| * parameter sections do not reject a header whose media type is unambiguously | ||
| * `application/json` (see `mediaTypeEssence` for the exact grammar). | ||
| */ | ||
| export function isJsonContentType(header: string | null | undefined): boolean { | ||
| // Fast path: the exact literal is what SDK clients send on every POST. | ||
| if (header === 'application/json') { | ||
| return true; | ||
| } | ||
| return mediaTypeEssence(header) === 'application/json'; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { isJsonContentType, mediaTypeEssence } from '../../src/shared/mediaType'; | ||
|
|
||
| describe('mediaTypeEssence', () => { | ||
| it('parses well-formed headers', () => { | ||
| expect(mediaTypeEssence('application/json')).toBe('application/json'); | ||
| expect(mediaTypeEssence('text/event-stream; charset=utf-8')).toBe('text/event-stream'); | ||
| expect(mediaTypeEssence('Application/JSON; charset=utf-8')).toBe('application/json'); | ||
| }); | ||
|
|
||
| it('falls back to the pre-parameter segment for malformed parameter sections', () => { | ||
| expect(mediaTypeEssence('application/json;')).toBe('application/json'); | ||
| expect(mediaTypeEssence('application/json; charset=')).toBe('application/json'); | ||
| expect(mediaTypeEssence('text/plain;')).toBe('text/plain'); | ||
| }); | ||
|
|
||
| it('returns undefined for missing or empty headers', () => { | ||
| expect(mediaTypeEssence(null)).toBeUndefined(); | ||
| expect(mediaTypeEssence(undefined)).toBeUndefined(); | ||
| expect(mediaTypeEssence('')).toBeUndefined(); | ||
| expect(mediaTypeEssence(' ')).toBeUndefined(); | ||
| expect(mediaTypeEssence(';charset=utf-8')).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('yields no essence for joined duplicate headers, with or without parameters', () => { | ||
| // Headers.get() joins repeated headers with ', '. Without parameters | ||
| // the comma lands in the first segment; with parameters it hides in | ||
| // the tail — both must behave the same. | ||
| expect(mediaTypeEssence('application/json, application/json')).toBe('application/json, application/json'); | ||
| expect(mediaTypeEssence('application/json; charset=utf-8, text/plain')).toBeUndefined(); | ||
| expect(mediaTypeEssence('application/json; charset=utf-8, application/json')).toBeUndefined(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('isJsonContentType', () => { | ||
| it('accepts application/json with or without parameters', () => { | ||
| expect(isJsonContentType('application/json')).toBe(true); | ||
| expect(isJsonContentType('application/json; charset=utf-8')).toBe(true); | ||
| expect(isJsonContentType('Application/JSON')).toBe(true); | ||
| expect(isJsonContentType(' application/json ; charset=utf-8')).toBe(true); | ||
| }); | ||
|
|
||
| it('accepts unambiguous media types with malformed parameter sections', () => { | ||
| expect(isJsonContentType('application/json;')).toBe(true); | ||
| expect(isJsonContentType('application/json; charset=')).toBe(true); | ||
| expect(isJsonContentType('application/json; charset=utf-8; charset=x')).toBe(true); | ||
| }); | ||
|
|
||
| it('never matches on substrings: parameters and sibling types are not application/json', () => { | ||
| expect(isJsonContentType('text/plain; a=application/json')).toBe(false); | ||
| expect(isJsonContentType('text/plain;')).toBe(false); | ||
| expect(isJsonContentType('text/plain, application/json')).toBe(false); | ||
| expect(isJsonContentType('application/json, application/json')).toBe(false); | ||
| expect(isJsonContentType('application/json; charset=utf-8, text/plain')).toBe(false); | ||
| expect(isJsonContentType('text/plain; charset=utf-8, application/json')).toBe(false); | ||
| expect(isJsonContentType('application/json-patch+json')).toBe(false); | ||
| expect(isJsonContentType('application/jsonp')).toBe(false); | ||
| }); | ||
|
|
||
| it('rejects missing, empty, and non-JSON types', () => { | ||
| expect(isJsonContentType(null)).toBe(false); | ||
| expect(isJsonContentType(undefined)).toBe(false); | ||
| expect(isJsonContentType('')).toBe(false); | ||
| expect(isJsonContentType('text/plain')).toBe(false); | ||
| expect(isJsonContentType('multipart/form-data')).toBe(false); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This wording is actually straight from WHATWG MIME:
https://mimesniff.spec.whatwg.org/#mime-type-essence
https://fetch.spec.whatwg.org/#cors-safelisted-request-header