From 75873270a59bd5d895322eee145738e95fb89258 Mon Sep 17 00:00:00 2001 From: Jay Date: Sun, 19 Apr 2026 15:32:32 +0200 Subject: [PATCH] fix: strip crlf correctly (#10758) --- lib/helpers/formDataToStream.js | 3 +- tests/unit/helpers/formDataToStream.test.js | 124 ++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 tests/unit/helpers/formDataToStream.test.js diff --git a/lib/helpers/formDataToStream.js b/lib/helpers/formDataToStream.js index f191e8cb..76127910 100644 --- a/lib/helpers/formDataToStream.js +++ b/lib/helpers/formDataToStream.js @@ -24,7 +24,8 @@ class FormDataPart { if (isStringValue) { value = textEncoder.encode(String(value).replace(/\r?\n|\r\n?/g, CRLF)); } else { - headers += `Content-Type: ${value.type || 'application/octet-stream'}${CRLF}`; + const safeType = String(value.type || 'application/octet-stream').replace(/[\r\n]/g, ''); + headers += `Content-Type: ${safeType}${CRLF}`; } this.headers = textEncoder.encode(headers + CRLF); diff --git a/tests/unit/helpers/formDataToStream.test.js b/tests/unit/helpers/formDataToStream.test.js new file mode 100644 index 00000000..68662e2b --- /dev/null +++ b/tests/unit/helpers/formDataToStream.test.js @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import formDataToStream from '../../../lib/helpers/formDataToStream.js'; + +class SpecFormData { + constructor() { + this._entries = []; + this[Symbol.toStringTag] = 'FormData'; + } + append(name, value) { + this._entries.push([name, value]); + } + entries() { + return this._entries[Symbol.iterator](); + } + [Symbol.iterator]() { + return this._entries[Symbol.iterator](); + } +} + +const makeBlobLike = ({ type, name, size, payload }) => ({ + type, + name, + size: size ?? payload.byteLength, + [Symbol.asyncIterator]: async function* () { + yield payload; + }, +}); + +const collect = async (stream) => { + const chunks = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +}; + +describe('formDataToStream (GHSA-445q-vr5w-6q77 CRLF injection)', () => { + it('should strip CRLF sequences from blob.type to prevent multipart header injection', async () => { + const fd = new SpecFormData(); + fd.append('photo', makeBlobLike({ + type: 'image/jpeg\r\nX-Injected-Header: PWNED\r\nX-Evil: bad', + name: 'photo.jpg', + payload: Buffer.from('PAYLOAD'), + })); + + const body = await collect(formDataToStream(fd, () => {})); + + expect(body).not.toContain('\r\nX-Injected-Header'); + expect(body).not.toContain('\r\nX-Evil'); + expect(body).toContain('Content-Type: image/jpegX-Injected-Header: PWNEDX-Evil: bad\r\n'); + }); + + it('should strip bare \\r and bare \\n from blob.type', async () => { + const fd = new SpecFormData(); + fd.append('f', makeBlobLike({ + type: 'text/plain\rX-A: 1\nX-B: 2', + name: 'f.txt', + payload: Buffer.from('x'), + })); + + const body = await collect(formDataToStream(fd, () => {})); + + expect(body).not.toMatch(/^X-A:/m); + expect(body).not.toMatch(/^X-B:/m); + }); + + it('should preserve legitimate Content-Type values', async () => { + const fd = new SpecFormData(); + fd.append('doc', makeBlobLike({ + type: 'application/json; charset=utf-8', + name: 'doc.json', + payload: Buffer.from('{}'), + })); + + const body = await collect(formDataToStream(fd, () => {})); + + expect(body).toContain('Content-Type: application/json; charset=utf-8\r\n'); + }); + + it('should default missing blob.type to application/octet-stream', async () => { + const fd = new SpecFormData(); + fd.append('bin', makeBlobLike({ + type: '', + name: 'bin', + payload: Buffer.from([0x00, 0x01]), + })); + + const body = await collect(formDataToStream(fd, () => {})); + + expect(body).toContain('Content-Type: application/octet-stream\r\n'); + }); + + it('should escape CRLF and quotes in blob.name (Content-Disposition)', async () => { + const fd = new SpecFormData(); + fd.append('up', makeBlobLike({ + type: 'text/plain', + name: 'evil\r\nX-Bad: 1".jpg', + payload: Buffer.from('x'), + })); + + const body = await collect(formDataToStream(fd, () => {})); + + expect(body).not.toContain('\r\nX-Bad: 1'); + expect(body).toContain('filename="evil%0D%0AX-Bad: 1%22.jpg"'); + }); + + it('should report stable contentLength that matches emitted bytes', async () => { + const fd = new SpecFormData(); + fd.append('photo', makeBlobLike({ + type: 'image/jpeg\r\nX-Injected: PWNED', + name: 'photo.jpg', + payload: Buffer.from('PAYLOAD'), + })); + + let reportedLength; + const stream = formDataToStream(fd, (h) => { + reportedLength = h['Content-Length']; + }, { boundary: 'test-boundary-abc' }); + + const body = await collect(stream); + + expect(Buffer.byteLength(body, 'utf8')).toBe(reportedLength); + }); +});