Skip to content

fix(zod-openapi): return 415 for request bodies with undeclared content type - #2087

Open
WolfieLeader wants to merge 1 commit into
honojs:mainfrom
WolfieLeader:fix/zod-openapi-body-validation-bypass
Open

fix(zod-openapi): return 415 for request bodies with undeclared content type#2087
WolfieLeader wants to merge 1 commit into
honojs:mainfrom
WolfieLeader:fix/zod-openapi-body-validation-bypass

Conversation

@WolfieLeader

Copy link
Copy Markdown
Contributor

Fixes #891.

Heads up: this could also have been fixed by defaulting request.body.required to true. I went the other way because that turns genuinely optional bodies into 400s and changes the emitted OpenAPI document. Close this if you'd rather have the simpler default.

The bug

For an optional body, the generated middleware validates only when the Content-Type header matches a declared media type. Any other Content-Type skips validation and injects {} as the validated data. The client controls the header, so it can bypass validation on any route with an optional body.

This route is enough to reproduce it:

const route = createRoute({
  method: 'post',
  path: '/posts',
  request: {
    body: { content: { 'application/json': { schema: z.object({ id: z.number() }) } } }, // required defaults to false
  },
  responses: { 200: { description: 'ok' } },
})

const app = new OpenAPIHono()

app.openapi(route, (c) => {
  const data = c.req.valid('json') // typed as { id: number }
  return c.json({ data })
})

Sending the same body under different Content-Types. The After column is this PR:

Body sent Content-Type Before After
{"id":7} application/json 200 · {"id":7} 200 · {"id":7}
{"id":7} application/vnd.api+json 200 · {"id":7} 200 · {"id":7}
{"id":7} text/plain 200 · {} 415
{"id":7} (omitted) 200 · {} 415
<post><id>7</id></post> application/xml 200 · {} 415
<post><id>7</id></post> text/xml 200 · {} 415
(none) (omitted) 200 · {} 200 · {}
{"id":7} (required: true) text/plain 400 415

data is typed { id: number }, so data.id is a number to the compiler while the runtime value is undefined.

XML was tested for the love of the game

That last row is the only behavior change beyond the fix itself: required: true with a mismatched Content-Type moves from 400 to 415. Hono's validator() already substitutes {} on a mismatch, so the schema was failing on an empty object rather than on the real problem.

The fix

A gate runs before the body validators:

  • Content-Type matches a declared media type -> validate as today.
  • Content-Type matches no declared media type -> 415.
  • No Content-Type, but a body was sent -> 415.
  • No Content-Type and no body -> unchanged: {} passes through for optional bodies, and required: true still fails the schema with 400.

The issue thread proposed 400 for this case. This PR returns 415 because RFC 9110 defines it for exactly this situation. Switching to 400 is a one-line change if you prefer.

One gap: only JSON and form media types get a validator, so a route declaring application/json and application/xml still gets {} when sent XML. Rejecting that would break XML-only routes, which are legitimate, so it needs a call.

  • Add tests
  • Run tests
  • pnpm changeset at the top of this repo and push the changeset
  • Follow the contribution guide

@changeset-bot

changeset-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c6a9541

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@hono/zod-openapi Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[zod-openapi][security] Unexpected validation bypass when omitting content-type

1 participant