From 1a0bb88246124123712fe3768aa3bafd87044db1 Mon Sep 17 00:00:00 2001 From: ferhat elmas Date: Fri, 13 Mar 2026 09:47:00 +0100 Subject: [PATCH] fix: prevent decoded content length spoofing Signed-off-by: ferhat elmas --- src/storage/uploader.ts | 7 ++++-- src/test/object.test.ts | 52 +++++++++++++++++++++++++++++++++++++++ src/test/uploader.test.ts | 21 ++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/storage/uploader.ts b/src/storage/uploader.ts index 6190c818..8b185487 100644 --- a/src/storage/uploader.ts +++ b/src/storage/uploader.ts @@ -297,8 +297,11 @@ export function validateMimeType(mimeType: string, allowedMimeTypes: string[]) { } function getKnownRequestContentLength(request: FastifyRequest): number | undefined { - const contentLengthHeader = - request.headers['x-amz-decoded-content-length'] ?? request.headers['content-length'] + // Only authenticated aws-chunked S3 requests get a verified decoded length. + const decodedContentLengthHeader = request.streamingSignatureV4 + ? request.headers['x-amz-decoded-content-length'] + : undefined + const contentLengthHeader = decodedContentLengthHeader ?? request.headers['content-length'] const contentLength = Number(contentLengthHeader) if (!Number.isFinite(contentLength) || contentLength < 0) { diff --git a/src/test/object.test.ts b/src/test/object.test.ts index 77944d8b..272d7959 100644 --- a/src/test/object.test.ts +++ b/src/test/object.test.ts @@ -946,6 +946,58 @@ describe('testing POST object via binary upload', () => { ) }) + test('return 400 when a binary upload spoofs x-amz-decoded-content-length', async () => { + mergeConfig({ + uploadFileSizeLimit: 1, + }) + + const bucketId = `spoof-decoded-${randomUUID()}` + const superUser = await getServiceKeyUser(tenantId) + const db = await getPostgresConnection({ + superUser, + user: superUser, + tenantId, + host: 'localhost', + }) + const setupTx = await db.transaction() + await setupTx.table('buckets').insert({ + id: bucketId, + name: bucketId, + public: true, + file_size_limit: null, + allowed_mime_types: null, + type: 'STANDARD', + }) + await setupTx.commit() + await db.dispose() + + const path = './src/test/assets/sadcat.jpg' + const { size } = fs.statSync(path) + + const headers = { + authorization: `Bearer ${await serviceKeyAsync}`, + 'Content-Length': size, + 'Content-Type': 'image/jpeg', + 'x-amz-decoded-content-length': '1', + } + + const response = await appInstance.inject({ + method: 'POST', + url: `/object/${bucketId}/public/sadcat-spoofed-decoded-length.jpg`, + headers, + payload: fs.createReadStream(path), + }) + expect(response.statusCode).toBe(400) + expect(response.body).toBe( + JSON.stringify({ + statusCode: '413', + error: 'Payload too large', + message: 'The object exceeded the maximum allowed size', + }) + ) + expect(S3Backend.prototype.uploadObject).toHaveBeenCalledTimes(1) + }) + test('return 400 when uploading to object with no file name', async () => { const path = './src/test/assets/sadcat.jpg' const { size } = fs.statSync(path) diff --git a/src/test/uploader.test.ts b/src/test/uploader.test.ts index 3cc52a8d..6f9a93c8 100644 --- a/src/test/uploader.test.ts +++ b/src/test/uploader.test.ts @@ -12,6 +12,7 @@ describe('fileUploadFromRequest', () => { 'x-amz-decoded-content-length': '123', }, raw: Readable.from(['payload']), + streamingSignatureV4: {} as FastifyRequest['streamingSignatureV4'], tenantId: 'stub-tenant', } as unknown as FastifyRequest, { @@ -22,4 +23,24 @@ describe('fileUploadFromRequest', () => { expect(upload.isTruncated()).toBe(false) }) + + test('ignores x-amz-decoded-content-length outside aws-chunked S3 uploads', async () => { + const upload = await fileUploadFromRequest( + { + headers: { + 'content-type': 'application/octet-stream', + 'content-length': '177', + 'x-amz-decoded-content-length': '123', + }, + raw: Readable.from(['payload']), + tenantId: 'stub-tenant', + } as unknown as FastifyRequest, + { + objectName: 'test.txt', + fileSizeLimit: 150, + } + ) + + expect(upload.isTruncated()).toBe(true) + }) })