Fastify vulnerable to Cross-Site Request Forgery (CSRF) attack via incorrect content type
Description
Fastify is a web framework with minimal overhead and plugin architecture. The attacker can use the incorrect Content-Type to bypass the Pre-Flight checking of fetch. fetch() requests with Content-Type’s essence as "application/x-www-form-urlencoded", "multipart/form-data", or "text/plain", could potentially be used to invoke routes that only accepts application/json content type, thus bypassing any CORS protection, and therefore they could lead to a Cross-Site Request Forgery attack. This issue has been patched in version 4.10.2 and 3.29.4. As a workaround, implement Cross-Site Request Forgery protection using `@fastify/csrf'.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
fastifynpm | >= 4.0.0, < 4.10.2 | 4.10.2 |
fastifynpm | >= 3.0.0, < 3.29.4 | 3.29.4 |
Affected products
1Patches
162dde76f1f7aMerge pull request from GHSA-3fjj-p79j-c9hh
4 files changed · +293 −9
lib/contentTypeParser.js+75 −6 modified@@ -2,6 +2,8 @@ const { AsyncResource } = require('async_hooks') const lru = require('tiny-lru').lru +// TODO: find more perforamant solution +const { parse: parseContentType } = require('content-type') const secureJson = require('secure-json-parse') const { @@ -33,7 +35,7 @@ function ContentTypeParser (bodyLimit, onProtoPoisoning, onConstructorPoisoning) this.customParsers = new Map() this.customParsers.set('application/json', new Parser(true, false, bodyLimit, this[kDefaultJsonParse])) this.customParsers.set('text/plain', new Parser(true, false, bodyLimit, defaultPlainTextParser)) - this.parserList = ['application/json', 'text/plain'] + this.parserList = [new ParserListItem('application/json'), new ParserListItem('text/plain')] this.parserRegExpList = [] this.cache = lru(100) } @@ -66,7 +68,7 @@ ContentTypeParser.prototype.add = function (contentType, opts, parserFn) { this.customParsers.set('', parser) } else { if (contentTypeIsString) { - this.parserList.unshift(contentType) + this.parserList.unshift(new ParserListItem(contentType)) } else { this.parserRegExpList.unshift(contentType) } @@ -97,11 +99,20 @@ ContentTypeParser.prototype.getParser = function (contentType) { const parser = this.cache.get(contentType) if (parser !== undefined) return parser + const parsed = safeParseContentType(contentType) + + // dummyContentType always the same object + // we can use === for the comparsion and return early + if (parsed === dummyContentType) { + return this.customParsers.get('') + } + // eslint-disable-next-line no-var for (var i = 0; i !== this.parserList.length; ++i) { - const parserName = this.parserList[i] - if (contentType.indexOf(parserName) !== -1) { - const parser = this.customParsers.get(parserName) + const parserListItem = this.parserList[i] + if (compareContentType(parsed, parserListItem)) { + const parser = this.customParsers.get(parserListItem.name) + // we set request content-type in cache to reduce parsing of MIME type this.cache.set(contentType, parser) return parser } @@ -110,8 +121,9 @@ ContentTypeParser.prototype.getParser = function (contentType) { // eslint-disable-next-line no-var for (var j = 0; j !== this.parserRegExpList.length; ++j) { const parserRegExp = this.parserRegExpList[j] - if (parserRegExp.test(contentType)) { + if (compareRegExpContentType(contentType, parsed.type, parserRegExp)) { const parser = this.customParsers.get(parserRegExp.toString()) + // we set request content-type in cache to reduce parsing of MIME type this.cache.set(contentType, parser) return parser } @@ -346,6 +358,63 @@ function removeAllContentTypeParsers () { this[kContentTypeParser].removeAll() } +// dummy here to prevent repeated object creation +const dummyContentType = { type: '', parameters: Object.create(null) } + +function safeParseContentType (contentType) { + try { + return parseContentType(contentType) + } catch (err) { + return dummyContentType + } +} + +function compareContentType (contentType, parserListItem) { + if (parserListItem.isEssence) { + // we do essence check + return contentType.type.indexOf(parserListItem) !== -1 + } else { + // when the content-type includes parameters + // we do a full-text search + // reject essence content-type before checking parameters + if (contentType.type.indexOf(parserListItem.type) === -1) return false + for (const key of parserListItem.parameterKeys) { + // reject when missing parameters + if (!(key in contentType.parameters)) return false + // reject when parameters do not match + if (contentType.parameters[key] !== parserListItem.parameters[key]) return false + } + return true + } +} + +function compareRegExpContentType (contentType, essenceMIMEType, regexp) { + if (regexp.source.indexOf(';') === -1) { + // we do essence check + return regexp.test(essenceMIMEType) + } else { + // when the content-type includes parameters + // we do a full-text match + return regexp.test(contentType) + } +} + +function ParserListItem (contentType) { + this.name = contentType + // we pre-calculate all the needed information + // before content-type comparsion + const parsed = safeParseContentType(contentType) + this.type = parsed.type + this.parameters = parsed.parameters + this.parameterKeys = Object.keys(parsed.parameters) + this.isEssence = contentType.indexOf(';') === -1 +} + +// used in ContentTypeParser.remove +ParserListItem.prototype.toString = function () { + return this.name +} + module.exports = ContentTypeParser module.exports.helpers = { buildContentTypeParser,
package.json+1 −0 modified@@ -176,6 +176,7 @@ "@fastify/fast-json-stringify-compiler": "^4.1.0", "abstract-logging": "^2.0.1", "avvio": "^8.2.0", + "content-type": "^1.0.4", "find-my-way": "^7.3.0", "light-my-request": "^5.6.1", "pino": "^8.5.0",
test/content-parser.test.js+214 −0 modified@@ -395,3 +395,217 @@ test('Safeguard against malicious content-type / 3', async t => { t.same(response.statusCode, 415) }) + +test('Safeguard against content-type spoofing - string', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser('text/plain', function (request, body, done) { + t.pass('should be called') + done(null, body) + }) + fastify.addContentTypeParser('application/json', function (request, body, done) { + t.fail('shouldn\'t be called') + done(null, body) + }) + + fastify.post('/', async () => { + return 'ok' + }) + + await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'text/plain; content-type="application/json"' + }, + body: '' + }) +}) + +test('Safeguard against content-type spoofing - regexp', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser(/text\/plain/, function (request, body, done) { + t.pass('should be called') + done(null, body) + }) + fastify.addContentTypeParser(/application\/json/, function (request, body, done) { + t.fail('shouldn\'t be called') + done(null, body) + }) + + fastify.post('/', async () => { + return 'ok' + }) + + await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'text/plain; content-type="application/json"' + }, + body: '' + }) +}) + +test('content-type match parameters - string 1', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser('text/plain; charset=utf8', function (request, body, done) { + t.fail('shouldn\'t be called') + done(null, body) + }) + fastify.addContentTypeParser('application/json; charset=utf8', function (request, body, done) { + t.pass('should be called') + done(null, body) + }) + + fastify.post('/', async () => { + return 'ok' + }) + + await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json; charset=utf8' + }, + body: '' + }) +}) + +test('content-type match parameters - string 2', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser('application/json; charset=utf8; foo=bar', function (request, body, done) { + t.pass('should be called') + done(null, body) + }) + fastify.addContentTypeParser('text/plain; charset=utf8; foo=bar', function (request, body, done) { + t.fail('shouldn\'t be called') + done(null, body) + }) + + fastify.post('/', async () => { + return 'ok' + }) + + await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json; foo=bar; charset=utf8' + }, + body: '' + }) +}) + +test('content-type match parameters - regexp', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser(/application\/json; charset=utf8/, function (request, body, done) { + t.pass('should be called') + done(null, body) + }) + + fastify.post('/', async () => { + return 'ok' + }) + + await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json; charset=utf8' + }, + body: '' + }) +}) + +test('content-type fail when parameters not match - string 1', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser('application/json; charset=utf8; foo=bar', function (request, body, done) { + t.fail('shouldn\'t be called') + done(null, body) + }) + + fastify.post('/', async () => { + return 'ok' + }) + + const response = await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json; charset=utf8' + }, + body: '' + }) + + t.same(response.statusCode, 415) +}) + +test('content-type fail when parameters not match - string 2', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser('application/json; charset=utf8; foo=bar', function (request, body, done) { + t.fail('shouldn\'t be called') + done(null, body) + }) + + fastify.post('/', async () => { + return 'ok' + }) + + const response = await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json; charset=utf8; foo=baz' + }, + body: '' + }) + + t.same(response.statusCode, 415) +}) + +test('content-type fail when parameters not match - regexp', async t => { + t.plan(1) + + const fastify = Fastify() + fastify.removeAllContentTypeParsers() + fastify.addContentTypeParser(/application\/json; charset=utf8; foo=bar/, function (request, body, done) { + t.fail('shouldn\'t be called') + done(null, body) + }) + + fastify.post('/', async () => { + return 'ok' + }) + + const response = await fastify.inject({ + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json; charset=utf8' + }, + body: '' + }) + + t.same(response.statusCode, 415) +})
test/custom-parser.test.js+3 −3 modified@@ -1053,7 +1053,7 @@ test('The charset should not interfere with the content type handling', t => { url: getUrl(fastify), body: '{"hello":"world"}', headers: { - 'Content-Type': 'application/json charset=utf-8' + 'Content-Type': 'application/json; charset=utf-8' } }, (err, response, body) => { t.error(err) @@ -1236,7 +1236,7 @@ test('contentTypeParser should add a custom parser with RegExp value', t => { url: getUrl(fastify), body: '{"hello":"world"}', headers: { - 'Content-Type': 'weird-content-type+json' + 'Content-Type': 'weird/content-type+json' } }, (err, response, body) => { t.error(err) @@ -1266,7 +1266,7 @@ test('contentTypeParser should add multiple custom parsers with RegExp values', done(null, 'xml') }) - fastify.addContentTypeParser(/.*\+myExtension$/, function (req, payload, done) { + fastify.addContentTypeParser(/.*\+myExtension$/i, function (req, payload, done) { let data = '' payload.on('data', chunk => { data += chunk }) payload.on('end', () => {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-3fjj-p79j-c9hhghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-41919ghsaADVISORY
- github.com/fastify/fastify/commit/62dde76f1f7aca76e38625fe8d983761f26e6fc9ghsaWEB
- github.com/fastify/fastify/security/advisories/GHSA-3fjj-p79j-c9hhghsaWEB
- www.npmjs.com/package/@fastify/csrfghsaWEB
- www.npmjs.com/package/%40fastify/csrfmitre
News mentions
0No linked articles in our index yet.