From 0fac0053a7d0ac3e0d60ea6b3a8794dce24d3f6d Mon Sep 17 00:00:00 2001 From: Marceli Pawlinski Date: Tue, 28 Jul 2026 22:26:24 +0100 Subject: [PATCH] fix: validate Response status range and null-body status per fetch spec Add spec-compliant validation to the Response constructor: - Throw RangeError for status outside 200-599 - Throw TypeError for body provided with null-body status (204, 304) - Validate statusText for invalid characters - Modify Response.error() to bypass constructor validation - Use null body for internal 204/304 responses Closes #1685 --- src/index.js | 8 +++++++- src/response.js | 40 ++++++++++++++++++++++++++++++++++------ test/response.js | 22 ++++++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/index.js b/src/index.js index 7c4aee87b..8d68a2795 100644 --- a/src/index.js +++ b/src/index.js @@ -282,7 +282,13 @@ export default async function fetch(url, options_) { // 3. no Content-Encoding header // 4. no content response (204) // 5. content not modified response (304) - if (!request.compress || request.method === 'HEAD' || codings === null || response_.statusCode === 204 || response_.statusCode === 304) { + if (response_.statusCode === 204 || response_.statusCode === 304) { + response = new Response(null, responseOptions); + resolve(response); + return; + } + + if (!request.compress || request.method === 'HEAD' || codings === null) { response = new Response(body, responseOptions); resolve(response); return; diff --git a/src/response.js b/src/response.js index 9806c0cba..c2665edd5 100644 --- a/src/response.js +++ b/src/response.js @@ -9,6 +9,7 @@ import Body, {clone, extractContentType} from './body.js'; import {isRedirect} from './utils/is-redirect.js'; const INTERNALS = Symbol('Response internals'); +const NULL_BODY_STATUSES = new Set([101, 204, 304]); /** * Response class @@ -26,12 +27,31 @@ export default class Response extends Body { // eslint-disable-next-line no-eq-null, eqeqeq, no-negated-condition const status = options.status != null ? options.status : 200; + // Validate status per fetch spec + if (status < 200 || status > 599) { + throw new RangeError(`Failed to construct 'Response': The status provided (${status}) is outside the range [200, 599].`); + } + + if (options.statusText !== undefined) { + // Validate statusText per fetch spec (reason-phrase token: HTAB, SP, VCHAR, obs-text) + // Disallow CR, LF, and NUL + if (/[\0\r\n]/.test(options.statusText)) { + throw new TypeError('Failed to construct \'Response\': The status text provided contains invalid characters.'); + } + } + const headers = new Headers(options.headers); - if (body !== null && !headers.has('Content-Type')) { - const contentType = extractContentType(body, this); - if (contentType) { - headers.append('Content-Type', contentType); + if (body !== null && body !== undefined) { + if (NULL_BODY_STATUSES.has(status)) { + throw new TypeError('Failed to construct \'Response\': Response with null body status cannot have body.'); + } + + if (!headers.has('Content-Type')) { + const contentType = extractContentType(body, this); + if (contentType) { + headers.append('Content-Type', contentType); + } } } @@ -119,8 +139,16 @@ export default class Response extends Body { } static error() { - const response = new Response(null, {status: 0, statusText: ''}); - response[INTERNALS].type = 'error'; + const response = Reflect.construct(Response, [], Response); + response[INTERNALS] = { + type: 'error', + url: '', + status: 0, + statusText: '', + headers: new Headers(), + counter: 0, + highWaterMark: 0 + }; return response; } diff --git a/test/response.js b/test/response.js index b5157623f..cfa501fa6 100644 --- a/test/response.js +++ b/test/response.js @@ -256,6 +256,28 @@ describe('Response', () => { expect(res.statusText).to.equal(''); }); + it('should throw RangeError when status is outside range 200-599', () => { + expect(() => new Response(null, {status: 199})).to.throw(RangeError); + expect(() => new Response(null, {status: 600})).to.throw(RangeError); + expect(() => new Response(null, {status: 0})).to.throw(RangeError); + }); + + it('should accept valid status codes', () => { + expect(() => new Response(null, {status: 200})).to.not.throw(); + expect(() => new Response(null, {status: 599})).to.not.throw(); + }); + + it('should throw TypeError when body is given with null body status', () => { + expect(() => new Response('body', {status: 204})).to.throw(TypeError); + expect(() => new Response('body', {status: 304})).to.throw(TypeError); + }); + + it('should allow null body with null body status', () => { + expect(() => new Response(null, {status: 204})).to.not.throw(); + expect(() => new Response(null, {status: 304})).to.not.throw(); + expect(() => new Response(undefined, {status: 204})).to.not.throw(); + }); + it('should warn once when using .data (response)', () => new Promise(resolve => { process.once('warning', evt => { expect(evt.message).to.equal('data doesn\'t exist, use json(), text(), arrayBuffer(), or body instead');