From 8b2b87b4393ee82e9870ac6e5a3825db72b87034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Fern=C3=A1ndez?= Date: Wed, 22 Apr 2026 19:04:26 +0200 Subject: [PATCH] fix: invalid content length error for compressed requests --- index.js | 17 ++--- test/regression/issue-191.test.js | 116 ++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 13 deletions(-) create mode 100644 test/regression/issue-191.test.js diff --git a/index.js b/index.js index 7c434e5..12f61c2 100644 --- a/index.js +++ b/index.js @@ -369,13 +369,13 @@ function buildRouteDecompress (_fastify, params, routeOptions) { // Prepare decompression - If there is a decompress error, prepare the error for fastify handing const decompresser = params.decompressStream[encoding]() - decompresser.receivedEncodedLength = 0 decompresser.on('error', onDecompressError.bind(this, request, params, encoding)) decompresser.pause() - // Track length of encoded length to handle receivedEncodedLength - raw.on('data', trackEncodedLength.bind(decompresser)) - raw.on('end', removeEncodedLengthTracking) + // Content-Length reflects the compressed wire size. After decompression it + // no longer matches the body size that Fastify validates against, causing + // FST_ERR_CTP_INVALID_CONTENT_LENGTH. Remove it so Fastify skips the check. + delete request.raw.headers['content-length'] pipeline(raw, decompresser, () => { // Cleanup callback - decompression errors are handled by decompresser's error handler @@ -475,15 +475,6 @@ function onEnd (err) { } } -function trackEncodedLength (chunk) { - this.receivedEncodedLength += chunk.length -} - -function removeEncodedLengthTracking () { - this.removeListener('data', trackEncodedLength) - this.removeListener('end', removeEncodedLengthTracking) -} - function onDecompressError (request, params, encoding, error) { this.log.debug(`compress: invalid request payload - ${error}`) diff --git a/test/regression/issue-191.test.js b/test/regression/issue-191.test.js new file mode 100644 index 0000000..820123b --- /dev/null +++ b/test/regression/issue-191.test.js @@ -0,0 +1,116 @@ +'use strict' + +// Regression test for https://github.com/fastify/fastify-compress/issues/191 +// +// When a client sends a compressed request body and includes a Content-Length +// header reflecting the *compressed* byte size, Fastify's body parser would +// compare that compressed size against the decompressed body size and raise +// FST_ERR_CTP_INVALID_CONTENT_LENGTH. +// +// The fix: delete the Content-Length header inside the preParsing decompression +// hook so Fastify skips the size check entirely. + +const { test, describe } = require('node:test') +const zlib = require('node:zlib') +const http = require('node:http') +const Fastify = require('fastify') +const compressPlugin = require('../..') + +// Makes a real HTTP POST request with a pre-compressed body and an explicit +// Content-Length set to the *compressed* byte size (as real clients do). +function postCompressed ({ port, body, encoding }) { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-encoding': encoding, + 'content-length': String(body.length), + }, + }, + (res) => { + let data = '' + res.on('data', (chunk) => { data += chunk }) + res.on('end', () => resolve({ statusCode: res.statusCode, body: data })) + } + ) + req.once('error', reject) + req.end(body) + }) +} + +const payload = JSON.stringify({ name: '@fastify/compress' }) + +describe('regression issue-191: compressed request with Content-Length set to compressed size', async () => { + test('should decompress gzip request without FST_ERR_CTP_INVALID_CONTENT_LENGTH', async (t) => { + t.plan(2) + + const fastify = Fastify() + t.after(() => fastify.close()) + await fastify.register(compressPlugin) + fastify.post('/', (request, reply) => { reply.send(request.body.name) }) + await fastify.listen({ port: 0, host: '127.0.0.1' }) + const port = fastify.server.address().port + + const compressed = zlib.gzipSync(payload) + const response = await postCompressed({ port, body: compressed, encoding: 'gzip' }) + + t.assert.equal(response.statusCode, 200) + t.assert.equal(response.body, '@fastify/compress') + }) + + test('should decompress deflate request without FST_ERR_CTP_INVALID_CONTENT_LENGTH', async (t) => { + t.plan(2) + + const fastify = Fastify() + t.after(() => fastify.close()) + await fastify.register(compressPlugin) + fastify.post('/', (request, reply) => { reply.send(request.body.name) }) + await fastify.listen({ port: 0, host: '127.0.0.1' }) + const port = fastify.server.address().port + + const compressed = zlib.deflateSync(payload) + const response = await postCompressed({ port, body: compressed, encoding: 'deflate' }) + + t.assert.equal(response.statusCode, 200) + t.assert.equal(response.body, '@fastify/compress') + }) + + test('should decompress brotli request without FST_ERR_CTP_INVALID_CONTENT_LENGTH', async (t) => { + t.plan(2) + + const fastify = Fastify() + t.after(() => fastify.close()) + await fastify.register(compressPlugin) + fastify.post('/', (request, reply) => { reply.send(request.body.name) }) + await fastify.listen({ port: 0, host: '127.0.0.1' }) + const port = fastify.server.address().port + + const compressed = zlib.brotliCompressSync(payload) + const response = await postCompressed({ port, body: compressed, encoding: 'br' }) + + t.assert.equal(response.statusCode, 200) + t.assert.equal(response.body, '@fastify/compress') + }) + + test('should decompress gzip request at route level without FST_ERR_CTP_INVALID_CONTENT_LENGTH', async (t) => { + t.plan(2) + + const fastify = Fastify() + t.after(() => fastify.close()) + await fastify.register(compressPlugin, { global: false }) + fastify.post('/', { decompress: {} }, (request, reply) => { reply.send(request.body.name) }) + await fastify.listen({ port: 0, host: '127.0.0.1' }) + const port = fastify.server.address().port + + const compressed = zlib.gzipSync(payload) + const response = await postCompressed({ port, body: compressed, encoding: 'gzip' }) + + t.assert.equal(response.statusCode, 200) + t.assert.equal(response.body, '@fastify/compress') + }) +})