Skip to content

fix: parse MIME types with WHATWG - #6919

Closed
bcomnes wants to merge 1 commit into
fastify:mainfrom
bcomnes:bret/demonstrate-loose-content-type-validation
Closed

bcomnes wants to merge 1 commit into
fastify:mainfrom
bcomnes:bret/demonstrate-loose-content-type-validation

Conversation

@bcomnes

@bcomnes bcomnes commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #6414.

I'm trying to find an approach that makes sense here:

  • Keep addContentTypeParser as the place for application-specific handling, including exact string overrides for valid values and explicitly registered RegExp parsers for invalid-header recovery, rather than forcing recovery into an onRequest hook (restored by Allow custom parsers to recover content types #6918).
  • Use a parsing and validation algorithm that is strict and standards-based while still providing established recovery behavior for common malformed inputs.

I suggest that Fastify parse Content-Type values using the WHATWG MIME type parsing algorithm.

This:

  • recovers usable MIME types from malformed parameter syntax as specified by WHATWG
  • continues to reject malformed type/subtype values
  • preserves parsed parameters for content-type parser matching and reply charset handling
  • moves MIME parsing into a dedicated cursor-based parser
  • avoids regular-expression backtracking over parameter lists
  • improves microbenchmark performance for parameterized and recovery cases
  • uses a well-known algorithm maintained in a WHATWG standard

Fastify currently parses MIME parameters using a collection of regular expressions. That differs from the WHATWG recovery algorithm and can produce inconsistent results for malformed parameter syntax.

The observed webhook input appears to begin with Content-Type being set more than once somewhere in the request path. An HTTP implementation or intermediary then combines those field lines with commas before Fastify receives them:

Content-Type: application/json; charset=utf-8
Content-Type: application/json

becomes:

application/json; charset=utf-8,application/json

The sender, intermediary, or server layer responsible for introducing the duplicate is not necessarily known. Fastify receives only the combined value.

Adopting the WHATWG algorithm also resolves other parsing ambiguities, such as duplicate parameters:

text/html; charset=windows-1252; charset=utf-8

The current Fastify parser lets the last parameter win:

{
  type: 'text',
  subtype: 'html',
  parameters: new Map([
    ['charset', 'utf-8']
  ])
}

WHATWG keeps the first parameter value:

{
  type: 'text',
  subtype: 'html',
  parameters: new Map([
    ['charset', 'windows-1252']
  ])
}

This follows the algorithm's directive to set a parameter only when that name does not already exist, preventing a later duplicate from changing the interpretation.

If the second Content-Type field conflicts with the first, the current parser silently discards it:

application/json; charset=utf-8,text/plain

The current Fastify parser matches the valid parameter prefix and parses this as:

{
  type: 'application',
  subtype: 'json',
  parameters: new Map([
    ['charset', 'utf-8']
  ])
}

The trailing ,text/plain is discarded.

WHATWG preserves the complete parameter value:

{
  type: 'application',
  subtype: 'json',
  parameters: new Map([
    ['charset', 'utf-8,text/plain']
  ])
}

The MIME type remains application/json, but the malformed charset value is no longer silently sanitized. This is nonsensical but more representative of reliantly, so in my opinion, preferable.

This does not make comma-separated media types generally valid:

application/json,application/json

The comma is part of the subtype in that case, so the value remains invalid. String parsers cannot register invalid MIME types, but applications can explicitly recover known invalid values using a RegExp content-type parser.

By contrast, comma-containing parameter values remain valid under WHATWG and use the parser registered for their media type. Applications that need custom behavior for a specific valid value can register an exact string parser.

For example:

fastify.addContentTypeParser(
  'application/json; charset=utf-8,text/plain',
  { parseAs: 'string' },
  customParser
)

Fastify normalizes the registered value and the incoming header to the same MIME type representation, so this exact string parser takes precedence over the generic application/json parser.

The parser follows:

The implementation uses numeric cursor positions and lazy string slices, but preserves the specified parsing behavior.

Parameter names are validated as HTTP tokens before using native String.prototype.toLowerCase(). Since valid HTTP tokens are ASCII, this is equivalent to the specification's ASCII-lowercase operation while avoiding Unicode case-folding surprises.

Performance

Compared with origin/main at a2f59056 using Node.js 24.18.0.

Each result is the paired median of 12 rounds with 500,000 constructions per round.

Benchmark code, run from the repository root:

'use strict'

const Module = require('node:module')
const { execFileSync } = require('node:child_process')
const { performance } = require('node:perf_hooks')

const repo = process.cwd()
const baseRef = process.env.BASE_REF || 'origin/main'

function compile (code, filename) {
  const compiledModule = new Module(filename, module.parent)
  compiledModule.filename = filename
  compiledModule.paths = Module._nodeModulePaths(`${repo}/lib`)
  compiledModule._compile(code, filename)
  return compiledModule.exports
}

const oldSource = execFileSync(
  'git',
  ['show', `${baseRef}:lib/content-type.js`],
  { cwd: repo, encoding: 'utf8' }
)
const implementations = {
  old: compile(oldSource, `${repo}/lib/content-type-old.js`),
  current: require(`${repo}/lib/content-type.js`)
}
const cases = [
  'application/json',
  'Application/JSON',
  'application/json; charset=utf-8',
  'application/json; charset=utf-8; boundary=abc',
  'application/json; name="foo;bar"; charset=utf-8',
  'application/json; charset=utf-8,evil'
]
const iterations = 500_000
const rounds = 12
let checksum = 0

function run (ContentType, input, count) {
  const start = performance.now()
  for (let index = 0; index < count; index++) {
    checksum += new ContentType(input).isValid
  }
  return count / (performance.now() - start) * 1000
}

function median (values) {
  const sorted = values.toSorted((left, right) => left - right)
  return (sorted[5] + sorted[6]) / 2
}

for (const input of cases) {
  const samples = { old: [], current: [] }
  run(implementations.old, input, 300_000)
  run(implementations.current, input, 300_000)

  for (let round = 0; round < rounds; round++) {
    const order = round % 2 === 0 ? ['old', 'current'] : ['current', 'old']
    for (const name of order) {
      samples[name].push(run(implementations[name], input, iterations))
    }
  }

  const oldResult = median(samples.old)
  const currentResult = median(samples.current)
  const change = (currentResult / oldResult - 1) * 100
  console.log(`${input}\n  old ${oldResult.toFixed(0)} ops/s  new ${currentResult.toFixed(0)} ops/s  ${change >= 0 ? '+' : ''}${change.toFixed(1)}%`)
}

console.log(`checksum ${checksum}`)
Input Change
application/json -0.7%
Application/JSON -0.8%
One parameter +15.9%
Two parameters +6.0%
Quoted parameter +12.0%
Malformed parameter recovery +8.1%

Plain MIME types are within benchmark noise of the previous implementation. Parameterized and recovery cases are faster.

Validation

  • npm run lint
  • npm run unit

Result: 2,310 tests passed, 4 skipped, 0 failed.

Checklist

Use the WHATWG MIME parsing algorithm to recover usable media types from malformed parameter syntax. Preserve parsed parameters for content type parser matching and reply charset handling.

Signed-off-by: Bret Comnes <bret@comnes.org>

@climba03003 climba03003 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.

From what I see from RFC9110, it is increasing the security risk for the server and encourage to provide a trigger to disable the sniffing. I actually against using the WHATWG Media Type Sniffing, the intermediate server or web browser should send a proper and valid Content-Type header.

In practice, resource owners do not always properly configure their origin server to provide the correct Content-Type for a given representation. Some user agents examine the content and, in certain cases, override the received type (for example, see [Sniffing]). This "MIME sniffing" risks drawing incorrect conclusions about the data, which might expose the user to additional security risks (e.g., "privilege escalation"). Furthermore, distinct media types often share a common data format, differing only in how the data is intended to be processed, which is impossible to distinguish by inspecting the data alone. When sniffing is implemented, implementers are encouraged to provide a means for the user to disable it.

@bcomnes

bcomnes commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor Author

I'm sorry I don't quite understand how this is different than what we are doing already, other than following a slightly different algorithm. This doesn't add any content sniffing. It uses a cursor based parsing technique on the header instead of regex.

Comment thread lib/mime-type-parser.js
@@ -0,0 +1,231 @@
'use strict'

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.

Please place the logic inside content-type.js no need to create multiple files serving a single purpose.

Comment thread test/content-type.test.js
})

test('recovers invalid parameter syntax', (t) => {
const recoveredContentTypes = [

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.

Instead of relying human fixed value, I would battle test the result between node:util.

const { MIMEType } = require('node:util')

test('application/json; charset=utf-8,application/json', (t) => {
      t.plan(4)
      const ct = new ContentType('application/json; charset=utf-8,application/json')
      const mt = new MIMEType('application/json; charset=utf-8,application/json')
      t.assert.strictEqual(ct.type, mt.type)
      t.assert.strictEqual(ct.subtype, mt.subtype)
      t.assert.strictEqual(ct.mediaType, mt.essence)
      t.assert.strictEqual(String(ct), String(mt))
})

@climba03003

Copy link
Copy Markdown
Member

Compared with origin/main at a2f5905 using Node.js 24.18.0.

How the performance between MIMEType and the current implementation?

@metcoder95

Copy link
Copy Markdown
Member

I honestly imagine similar performance degradation unless we take shortcuts on this implementation. Following the cursor based as per-spec is not the most performant unless tricks are done that can deviate from spec.

I'd go on the line of having a performance RFC based implementation that following WHATWG, not opposed to that if optional or lazy loaded

@jsumners jsumners 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.

Sorry, I understand the desire and justifications, but I am against backing out the work done to bring Fastify's content-type parser up to standard (RFC 9110). This is a constant source of "security" reports. A strict parser stops this.

For the record: I DO NOT consider WHATWG as being a "standard." We follow the IETF RFCs.

@bcomnes

bcomnes commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Going to rework this, see #6414 (comment)

@bcomnes bcomnes closed this Aug 7, 2026
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.

4 participants