Skip to content

chore: Updated content-type header parsing - #6414

Merged
jsumners merged 5 commits into
mainfrom
parse-content-type
Jan 26, 2026
Merged

jsumners merged 5 commits into
mainfrom
parse-content-type

Conversation

@jsumners

@jsumners jsumners commented Dec 14, 2025 •

Copy link
Copy Markdown
Member

Resolves #6332.

This is a follow-on from #6303. This introduces a more robust content-type header value parsing algorithm.

IMPORTANT: this results in much stricter content-type handling. Prior to this PR, we were accepting, and handling, headers like content-type: a-simple-string. That is not valid. There must be a type and a subtype present. So a "valid" form would be content-type: a-simple-string/foo.

This change also enforces normalization of the content-type value. For example, if the request has application/json; charset=utf-8 as the value, it will be normalized to application/json; charset="utf-8".

@jsumners
jsumners force-pushed the parse-content-type branch 3 times, most recently from c6e17ba to 817bc34 Compare December 14, 2025 18:41
@jsumners
jsumners marked this pull request as ready for review December 14, 2025 18:43
@jsumners
jsumners requested review from a team and mcollina December 14, 2025 18:47

@gurgunday gurgunday left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is hard to land in v5

Also, we should check how much it affects the performance

Unfortunately we removed the benchmark CI, someone should run it locally (I'll do it when I review)

@jsumners
jsumners marked this pull request as draft December 14, 2025 22:42
@jsumners
jsumners marked this pull request as ready for review December 15, 2025 00:20
@climba03003

Copy link
Copy Markdown
Member

It like a light weight version of content-type parser?
Removed the parameters parts, it is ok on our case because we never use it.

How the performance after the change?

@jsumners
jsumners force-pushed the parse-content-type branch 2 times, most recently from 93f288a to b620bfc Compare December 15, 2025 11:35
@jsumners

Copy link
Copy Markdown
Member Author

It like a light weight version of content-type parser? Removed the parameters parts, it is ok on our case because we never use it.

In our code base, "content-type parser" refers to the thing that collects and manages the functions used to parse HTTP POST bodies. Prior to this PR, the indicator used to denote what format those POST bodies is in was inspected in various ways that are very fragile. This PR introduces a dedicated identifier parser.

How the performance after the change?

I don't know. Likely slower than the previous fragile checks, but likely not the slowest possible. Unfortunately, proper handling of the content-type header value is an imperative. We cannot continue using the previous fragile inspections.

@jsumners jsumners added the bugfix Issue or PR that should land as semver patch label Dec 15, 2025

@mcollina mcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure that there are no content types without a /? I think a user could create one like that, and I feel we should kind of support it.

Comment thread lib/content-type.js Resolved
Comment thread lib/content-type.js Resolved
Comment thread lib/content-type.js Resolved
Comment thread lib/content-type.js Resolved
@jsumners

Copy link
Copy Markdown
Member Author

Are you sure that there are no content types without a /? I think a user could create one like that, and I feel we should kind of support it.

Please review https://httpwg.org/specs/rfc9110.html#media.type

The required format is type/subtype. Anything written as type is not spec compliant. We opt to adhere to the specs: https://github.com/fastify/fastify/blob/main/docs/Reference/Principles.md#specification-adherence

@jsumners

jsumners commented Dec 15, 2025 •

Copy link
Copy Markdown
Member Author

@mcollina I have written the benchmark below to test the performance of splitting out only the type and subtype parts. I agree that the IIFE is too expensive. It looks like the regular expression version becomes worth it as we start dealing with the parameters.

Benchmark
'use strict'

const assert = require('node:assert')
const { Suite } = require('bench-node')
const suite = new Suite({
  reporter
})

const basicMediaType = 'application/json'
const paramsMediaType = 'application/json; charset=utf-8; foo=bar'

suite.add('iife split (basic)', () => {
  let parts = (() => {
    const [first, ...rest] = basicMediaType.split('/')
    if (rest.length === 0) return [first]
    return [first, rest.join('/')]
  })()
  assert.equal(parts[0], 'application')
  assert.equal(parts[1], 'json')
})

suite.add('iife split (params)', () => {
  let parts = (() => {
    const [first, ...rest] = paramsMediaType.split('/')
    if (rest.length === 0) return [first]
    return [first, rest.join('/')]
  })()

  const type = parts[0]
  parts = parts[1].split(';')
  const subtype = parts[0]
  
  assert.equal(type, 'application')
  assert.equal(subtype, 'json')
})

suite.add('long split (basic)', () => {
  let parts = basicMediaType.split('/')
  assert.equal(parts[0], 'application')
  assert.equal(parts[1], 'json')
})

suite.add('long split (params)', () => {
  let parts = paramsMediaType.split('/')
  const type = parts[0]

  parts = parts[1].split(';')
  const subtype = parts[0]

  assert.equal(type, 'application')
  assert.equal(subtype, 'json')
})

const tokensReg = /^([\w!#$%&'*+.^`|~-]+)\/([\w!#$%&'*+.^`|~-]+)\s*;?/
suite.add('regex split (basic)', () => {
  const matches = tokensReg.exec(basicMediaType)
  const type = matches[1]
  const subtype = matches[2]
  assert.equal(type, 'application')
  assert.equal(subtype, 'json')
})

suite.add('regex split (params)', () => {
  const matches = tokensReg.exec(paramsMediaType)
  const type = matches[1]
  const subtype = matches[2]
  assert.equal(type, 'application')
  assert.equal(subtype, 'json')
})

suite.run()

const { format } = new Intl.NumberFormat('en-US', {
  useGrouping: true
})
function reporter (results) {

  for (const result of results) {
    const ops = format(result.opsSec)
    console.log(`${result.name}: ${ops} ops/sec`)
  }
}
❯ node --allow-natives-syntax index.js
iife split (basic): 9,655,587.18 ops/sec
iife split (params): 6,261,203.487 ops/sec
long split (basic): 15,462,605.433 ops/sec
long split (params): 8,142,902.648 ops/sec
regex split (basic): 15,020,238.273 ops/sec
regex split (params): 14,836,393.537 ops/sec

@Eomm Eomm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closes #6077

Minor suggestions

Comment thread lib/content-type-parser.js Resolved
Comment thread lib/content-type.js Resolved
Comment thread lib/content-type.js Resolved
Comment thread lib/content-type.js Resolved
@jsumners

Copy link
Copy Markdown
Member Author

I'll be getting back to this soon.

@jsumners
jsumners force-pushed the parse-content-type branch from 4523a2b to 126463b Compare January 1, 2026 18:58
Comment thread test/schema-validation.test.js Resolved
@maneeshaking

Copy link
Copy Markdown

Hi @jsumners and @mcollina ,

I've analyzed the blocker regarding the performance vs. accuracy of the header parsing. Since Regex is proving difficult for strictness and split has overhead, the most performant way to handle this in V8 is usually a direct indexOf slice.

Here is a proposed solution that fixes the vulnerability (adding the missing else check) and includes a high-performance parser that passes the tests without Regex complexity.

  1. Optimized Parser (Replaces getEssenceMediaType) This handles application/json; charset=utf-8 correctly without allocating arrays.
function getEssenceMediaType (header) {
  if (!header) return ''
  let end = header.indexOf(';')
  if (end === -1) end = header.length
  return header.slice(0, end).trim().toLowerCase()
}

The Logic Fix (Inside validate) This ensures we don't "Fail Open" if the content type is missing from the schema map.

// ... inside validate() ...
    } else if (context[bodySchema]) {
      const contentType = getEssenceMediaType(request.headers['content-type'])
      const contentSchema = context[bodySchema][contentType]
      
      if (contentSchema) {
        validatorFunction = contentSchema
      } else {
        // Fix: Fail closed if Content-Type is present but not defined in schema
        const error = new Error('Content-Type not supported')
        error.statusCode = 415
        error.code = 'FST_ERR_VALIDATION'
        return error
      }
    }

This approach should satisfy the 415 test expectation and resolve the performance regression.

@jsumners
jsumners requested a review from mcollina January 19, 2026 13:41
Co-authored-by: Manuel Spigolon <behemoth89@gmail.com>
Signed-off-by: James Sumners <321201+jsumners@users.noreply.github.com>
@jsumners
jsumners requested a review from Eomm January 19, 2026 13:42
@jsumners

Copy link
Copy Markdown
Member Author

Are you sure that there are no content types without a /? I think a user could create one like that, and I feel we should kind of support it.

Please review https://httpwg.org/specs/rfc9110.html#media.type

The required format is type/subtype. Anything written as type is not spec compliant. We opt to adhere to the specs: https://github.com/fastify/fastify/blob/main/docs/Reference/Principles.md#specification-adherence

@bcomnes

bcomnes commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Whats the appropriate workaround/approach to take when a client is serving up invalid headers?

@climba03003

Copy link
Copy Markdown
Member

Whats the appropriate workaround/approach to take when a client is serving up invalid headers?

It is not clear to me client means request or reply?
For request, the behavior should be similar as before.
Parsing do not happen when the Content-Type do not match the media type part.
If you want to ensure do something on the invalid media type, you can

fastify.post('/', async function(request, reply) {
  if(request.mediaType == undefined) {
    // You can use hook to do the same thing.
    throw Error('Invalid Content-Type')
  } else {
    reply.send({ ok: true })
  }
})

For response, developer should always ensure they send a valid header.

@bcomnes

bcomnes commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

I'm pretty confused, and I think maybe this isn't quite right yet.

RFC 9110 states:

“Unless noted otherwise, a recipient MAY attempt to recover a usable protocol element from an invalid construct.”

In a Fastify service that receives webhooks from Jira, for example, we observe Content-Type headers like:

"application/json,application/json"                  (rejected after this change)
"application/json; charset=utf-8,application/json"   (still accepted after this change)

Best I can tell, a request ends up with duplicate Content-Type headers and then some intermediary collapses them into a single header. By the time they reach Fastify, they are in this weird state.

We do not appear to enable joinDuplicateHeaders, so I suspect something in front of the service (either in the thing sending it or our infra). Either way, these are headers our service receives and cannot control. It might even be outside the client’s control if the request passes through other layers that introduce this behavior.

Previously, for application/json,application/json, I could add an encapsulated RegExp addContentTypeParser on the affected routes. Fastify 5.5 passed the raw header value through parser matching, our RegExp recognized the known malformed form, and we deliberately selected the JSON body parser. That seems like a good example of the application-specific recovery permitted by RFC 9110.

After this change, however, application/json,application/json is rejected before my custom parser is consulted. I instead have to handle it in an onRequest hook by parsing and mutating the header into a valid value before Fastify performs its validation.

I understand that addContentTypeParser manages body-parser selection rather than being a general-purpose header parser. However, registering a RegExp parser was previously a clear way to say, “For this known external header form, parse the body as FOO”

Is onRequest header mutation really the intended layer for recovering correctable external input? This seems like both a spec-conformance fix and a Fastify policy and extension-point change. I am open to being convinced otherwise, but this feels like behavior that should have been introduced behind an option or in a breaking release.

@climba03003, if my understanding is correct, checking request.mediaType in the handler would not work. The request handler never runs because Fastify responds with 415 Unsupported Media Type before entering it. When inspected from an onRequest hook, request.mediaType also appears to be "/" for this invalid value rather than undefined.

My main concerns are:

  • This broke a previously working use of an explicitly registered RegExp content-type parser, but it was released as a bug fix.
  • Stricter default validation may be appropriate, but preventing a registered parser from implementing an application-specific recovery policy is a separate behavioral change.
  • addContentTypeParser was a reasonably clear place to recover body-parser selection for a known malformed header. Why require applications to move that logic into onRequest and mutate the raw header instead?

bcomnes added a commit to bcomnes/fastify that referenced this pull request Aug 6, 2026
Keep malformed content types rejected by default while allowing an explicitly registered RegExp parser to recover known external values. Keep request handling independent of media type parsing so validation, normalization, and recovery remain encapsulated in the content type parser. Preserve existing catch-all and missing-route behavior.

Followup to fastify#6414
@bcomnes

bcomnes commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

I sketched out my idea here: #6918 @jsumners I think this actually still aligns with the intention here, while also making this less of a breaking change. Essentially it restores the ability to use a regxp addContentTypeParser to correct for bad headers, and doesn't force you to move this to onRequest while keeping the stricter header validation.

@bcomnes bcomnes mentioned this pull request Aug 7, 2026
4 tasks done
@bcomnes

bcomnes commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

I also put together a WHATWG token based parsing alternative, which retains the stronger validation of this PR, but uses a more forgiving and standards based approach to invalid header recovery. Please check it out #6919

It complements #6918 which restores the location of header validation.

@jsumners

jsumners commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

RFC 9110 states:

“Unless noted otherwise, a recipient MAY attempt to recover a usable protocol element from an invalid construct.”

"MAY" is not "MUST". As noted elsewhere, the Fastify project opts to strictly adhere to the RFCs.

I instead have to handle it in an onRequest hook by parsing and mutating the header into a valid value before Fastify performs its validation.

This sounds like you are able to handle these absolutely incorrect header values.

Is onRequest header mutation really the intended layer for recovering correctable external input? This seems like both a spec-conformance fix and a Fastify policy and extension-point change. I am open to being convinced otherwise, but this feels like behavior that should have been introduced behind an option or in a breaking release.

My position is: yes, the onRequest hook is the feature to use if you need to massage the request before it is processed.

The simple fact of the matter is that invalid content-type headers are constant source of "security" reports. My position will always be behind the strict parser, if for no reason other than to get those useless reports to stop.

@bcomnes

bcomnes commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

This sounds like you are able to handle these absolutely incorrect header values.

Yes of course I can accommodate it. But I had to change in a non-breaking update. My point is that you can still tighten the validation without moving where that happens. This change was also a breaking lifecycle policy change.

#6918 effectively keeps your change but avoids the breaking change which doesn't seem nessisary for any of the goals here unless I'm mistaken.

@jsumners

jsumners commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

My goals were accomplished. The content-type parser was permitting invalid headers to pass through and/or jump to incorrect logic. This PR fixed those bugs. I am against any change that restores the previous behavior.

@bcomnes

bcomnes commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The current implementation still lets some invalid headers through to addContentTypeParser. (Do you want test more cases?) Is the intention to strictly gate all of those out now? Clarifying intention here would help me understand what's acceptable here.

@jsumners

jsumners commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

I have seen you mention a case where a type+subtype is appended to the parameters list. That's just a bad parameters list and this feature explicitly opts to best effort those at this time. They are not used internally except to match incoming requests to the correct parser. A patch that rejects this malformed content in an efficient manner would be acceptable.

@bcomnes

bcomnes commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Just chatted with @mcollina on a call about this and have a better understanding of what the issue/constraints are.

  • The goal is to strictly drop invalid content type headers into addContentTypeParser, because the content-type narrowed schema validator can be bypassed by invalid content-type headers (kinda seems like an issue with the schema in an app implementation, but I guess its come up enough to necessitate this gate due to the validation guarantees). I may have found some invalid headers that are still getting through. Let me look into weather they matter or not for this concern.
  • Adding an option to disable the strict validation branch in into contentTypeParser.run in lib/handle-request.js is a possibility, or maybe adding a hook to swap in custom ContentParser implementation. Might be worth adding as an escape hatch due to breaking nature in a non-major release.
  • Mushing around content-type headers in onRequest into something valid is fine, maybe the preferred place to do it, but addContentTypeParser should document about this new limitation (they don't run if they don't pass fastify's validation).
  • The whatwg algo doesn't materially improve the security needs here, and the standards behavior defensibility isn't relevant for this issue.

A patch that rejects this malformed content in an efficient manner would be acceptable.

Will poke around if I can come up with something, thank you for the guidance.

@bcomnes

bcomnes commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Small docs PR: #6925

Explains the new behavior and hazard.

@bcomnes

bcomnes commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

This PR: #6926

Makes strict validation prior to consulting addContentTypeParser optional, defaulting to the new strict behavior.

@bcomnes

bcomnes commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

This PR: #6927

Adds a different/alternative/complementary escape hatch allowing callers to customize/extend/replace the ContentType parser.

@bcomnes

bcomnes commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

I sent some time trying to work through understand what is needed here wrt strict RFC 9110 validation, but also the lax trailer parsing, and normalization recovery that remains.

- \u00a0application/json
- application/json\u00a0
- application/json\u00a0; charset=utf-8

Looks like there might be some whitespace normalization leaks as these seems to leak through. Currently contentType.isValid seems to mean "Fastify recovered a usable type/subtype." Not: “The complete original header value conforms to the RFC 9110 Content-Type grammar.” Not sure what the intention here happy to expand this validation if people think its needed.

@jsumners

Copy link
Copy Markdown
Member Author

I honestly can't remember the full details around it. But my vague recollection is that parts of the codebase allowed for empty values while needing to distinguish such a value from an invalid header.

Improvements are welcome.

jsumners added a commit that referenced this pull request Sep 2, 2026
Document the request lifecycle boundary introduced by
#6414:

- malformed `Content-Type` values are rejected before body parser
selection
- string, RegExp, and catch-all parsers cannot recover invalid values
- an `onRequest` hook can narrowly canonicalize known external input
- repair must happen before `request.mediaType` is accessed and cached
- broad recovery can undermine parser selection and
`schema.body.content` guarantees

The example intentionally handles one exact malformed value and leaves
every unrelated value under Fastify strict validation.

<!--
Thank you for your pull request. Please provide a description above and
review
the requirements below.

Bug fixes and new features should include tests and possibly benchmarks.

Contributors guide:
https://github.com/fastify/fastify/blob/main/CONTRIBUTING.md

By making a contribution to this project, I certify that:

* (a) The contribution was created in whole or in part by me and I
  have the right to submit it under the open source license
  indicated in the file; or

* (b) The contribution is based upon previous work that, to the best
  of my knowledge, is covered under an appropriate open source
  license and I have the right under that license to submit that
  work with modifications, whether created in whole or in part
  by me, under the same open source license (unless I am
  permitted to submit under a different license), as indicated
  in the file; or

* (c) The contribution was provided directly to me by some other
  person who certified (a), (b) or (c) and I have not modified
  it.

* (d) I understand and agree that this project and the contribution
  are public and that a record of the contribution (including all
  personal information I submit with it, including my sign-off) is
  maintained indefinitely and may be redistributed consistent with
  this project or the open source license(s) involved.
-->

#### Checklist

- [x] run `npm run test && npm run benchmark --if-present`
- [x] tests and/or benchmarks are included
- [x] documentation is changed or added
- [x] commit message and code follows the [Developer's Certification of
Origin](https://github.com/fastify/.github/blob/main/CONTRIBUTING.md#developers-certificate-of-origin-11)
and the [Code of
conduct](https://github.com/fastify/.github/blob/main/CODE_OF_CONDUCT.md)

---------

Signed-off-by: Bret Comnes <bret@comnes.org>
Signed-off-by: Bret Comnes <166301+bcomnes@users.noreply.github.com>
Co-authored-by: James Sumners <321201+jsumners@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Issue or PR that should land as semver patch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Content-Type parsing is incorrect

10 participants