fix(server): validate Content-Type by parsed media type instead of substring match (#2441)

This commit is contained in:
Felix Weinberger 2026-07-06 20:29:58 +01:00 • committed by GitHub
commit 561c6d8345
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 452 additions and 20 deletions

View file

@ -1,3 +1,4 @@
import { isJsonContentType } from '@modelcontextprotocol/server';
import type { Context } from 'hono';
import { Hono } from 'hono';
@ -45,8 +46,8 @@ export interface CreateMcpHonoAppOptions {
* DNS rebinding attacks on localhost servers.
*
* This also installs a small JSON body parsing middleware (similar to `express.json()`)
* that stashes the parsed body into `c.set('parsedBody', ...)` when `Content-Type` includes
* `application/json`.
* that stashes the parsed body into `c.set('parsedBody', ...)` when the `Content-Type`
* media type is `application/json`.
*
* @param options - Configuration options
* @returns A configured Hono application
@ -63,8 +64,10 @@ export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono {
return await next();
}
const ct = c.req.header('content-type') ?? '';
if (!ct.includes('application/json')) {
// Parsed media type, never a substring match — see isJsonContentType.
// A body left unparsed here is answered 415 by the transport's own
// Content-Type check downstream.
if (!isJsonContentType(c.req.header('content-type'))) {
return await next();
}

View file

@ -90,6 +90,34 @@ describe('@modelcontextprotocol/hono', () => {
expect(await res.text()).toBe('Invalid JSON');
});
test('createMcpHonoApp does not parse a non-JSON media type whose parameters contain application/json', async () => {
const app = createMcpHonoApp();
app.post('/echo', (c: Context) => c.json({ parsed: c.get('parsedBody') ?? null }));
// `text/plain; a=application/json` contains the substring but its media
// type is text/plain — it must never be treated as a JSON body.
const res = await app.request('http://localhost/echo', {
method: 'POST',
headers: { Host: 'localhost:3000', 'content-type': 'text/plain; a=application/json' },
body: JSON.stringify({ a: 1 })
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ parsed: null });
});
test('createMcpHonoApp parses application/json with parameters', async () => {
const app = createMcpHonoApp();
app.post('/echo', (c: Context) => c.json(c.get('parsedBody')));
const res = await app.request('http://localhost/echo', {
method: 'POST',
headers: { Host: 'localhost:3000', 'content-type': 'application/json; charset=utf-8' },
body: JSON.stringify({ a: 1 })
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ a: 1 });
});
test('createMcpHonoApp does not override parsedBody if upstream middleware set it', async () => {
const app = createMcpHonoApp();
app.use('/echo', async (c: Context, next) => {