From e5540dcafe423909527c0af097fb6ae7f68c08f3 Mon Sep 17 00:00:00 2001 From: Jay Date: Wed, 22 Apr 2026 18:54:12 +0200 Subject: [PATCH] fix: fetch adaptor is not enforcing max body or content length (#10795) --- lib/adapters/fetch.js | 105 ++++++++++++- lib/helpers/estimateDataURLDecodedBytes.js | 13 +- tests/unit/adapters/fetch.test.js | 165 +++++++++++++++++++++ 3 files changed, 280 insertions(+), 3 deletions(-) diff --git a/lib/adapters/fetch.js b/lib/adapters/fetch.js index 0c4a4d5e..c61642b5 100644 --- a/lib/adapters/fetch.js +++ b/lib/adapters/fetch.js @@ -11,6 +11,7 @@ import { } from '../helpers/progressEventReducer.js'; import resolveConfig from '../helpers/resolveConfig.js'; import settle from '../core/settle.js'; +import estimateDataURLDecodedBytes from '../helpers/estimateDataURLDecodedBytes.js'; const DEFAULT_CHUNK_SIZE = 64 * 1024; @@ -163,8 +164,13 @@ const factory = (env) => { headers, withCredentials = 'same-origin', fetchOptions, + maxContentLength, + maxBodyLength, } = resolveConfig(config); + const hasMaxContentLength = utils.isNumber(maxContentLength) && maxContentLength > -1; + const hasMaxBodyLength = utils.isNumber(maxBodyLength) && maxBodyLength > -1; + let _fetch = envFetch || fetch; responseType = responseType ? (responseType + '').toLowerCase() : 'text'; @@ -186,6 +192,41 @@ const factory = (env) => { let requestContentLength; try { + // Enforce maxContentLength for data: URLs up-front so we never materialize + // an oversized payload. The HTTP adapter applies the same check (see http.js + // "if (protocol === 'data:')" branch). + if (hasMaxContentLength && typeof url === 'string' && url.startsWith('data:')) { + const estimated = estimateDataURLDecodedBytes(url); + if (estimated > maxContentLength) { + throw new AxiosError( + 'maxContentLength size of ' + maxContentLength + ' exceeded', + AxiosError.ERR_BAD_RESPONSE, + config, + request + ); + } + } + + // Enforce maxBodyLength against the outbound request body before dispatch. + // Mirrors http.js behavior (ERR_BAD_REQUEST / 'Request body larger than + // maxBodyLength limit'). Skip when the body length cannot be determined + // (e.g. a live ReadableStream supplied by the caller). + if (hasMaxBodyLength && method !== 'get' && method !== 'head') { + const outboundLength = await resolveBodyLength(headers, data); + if ( + typeof outboundLength === 'number' && + isFinite(outboundLength) && + outboundLength > maxBodyLength + ) { + throw new AxiosError( + 'Request body larger than maxBodyLength limit', + AxiosError.ERR_BAD_REQUEST, + config, + request + ); + } + } + if ( onUploadProgress && supportsRequestStream && @@ -252,10 +293,27 @@ const factory = (env) => { ? _fetch(request, fetchOptions) : _fetch(url, resolvedOptions)); + // Cheap pre-check: if the server honestly declares a content-length that + // already exceeds the cap, reject before we start streaming. + if (hasMaxContentLength) { + const declaredLength = utils.toFiniteNumber(response.headers.get('content-length')); + if (declaredLength != null && declaredLength > maxContentLength) { + throw new AxiosError( + 'maxContentLength size of ' + maxContentLength + ' exceeded', + AxiosError.ERR_BAD_RESPONSE, + config, + request + ); + } + } + const isStreamResponse = supportsResponseStream && (responseType === 'stream' || responseType === 'response'); - if (supportsResponseStream && (onDownloadProgress || (isStreamResponse && unsubscribe))) { + if ( + supportsResponseStream && + (onDownloadProgress || hasMaxContentLength || (isStreamResponse && unsubscribe)) + ) { const options = {}; ['status', 'statusText', 'headers'].forEach((prop) => { @@ -272,8 +330,24 @@ const factory = (env) => { )) || []; + let bytesRead = 0; + const onChunkProgress = (loadedBytes) => { + if (hasMaxContentLength) { + bytesRead = loadedBytes; + if (bytesRead > maxContentLength) { + throw new AxiosError( + 'maxContentLength size of ' + maxContentLength + ' exceeded', + AxiosError.ERR_BAD_RESPONSE, + config, + request + ); + } + } + onProgress && onProgress(loadedBytes); + }; + response = new Response( - trackStream(response.body, DEFAULT_CHUNK_SIZE, onProgress, () => { + trackStream(response.body, DEFAULT_CHUNK_SIZE, onChunkProgress, () => { flush && flush(); unsubscribe && unsubscribe(); }), @@ -288,6 +362,33 @@ const factory = (env) => { config ); + // Fallback enforcement for environments without ReadableStream support + // (legacy runtimes). Detect materialized size from typed output; skip + // streams/Response passthrough since the user will read those themselves. + if (hasMaxContentLength && !supportsResponseStream && !isStreamResponse) { + let materializedSize; + if (responseData != null) { + if (typeof responseData.byteLength === 'number') { + materializedSize = responseData.byteLength; + } else if (typeof responseData.size === 'number') { + materializedSize = responseData.size; + } else if (typeof responseData === 'string') { + materializedSize = + typeof TextEncoder === 'function' + ? new TextEncoder().encode(responseData).byteLength + : responseData.length; + } + } + if (typeof materializedSize === 'number' && materializedSize > maxContentLength) { + throw new AxiosError( + 'maxContentLength size of ' + maxContentLength + ' exceeded', + AxiosError.ERR_BAD_RESPONSE, + config, + request + ); + } + } + !isStreamResponse && unsubscribe && unsubscribe(); return await new Promise((resolve, reject) => { diff --git a/lib/helpers/estimateDataURLDecodedBytes.js b/lib/helpers/estimateDataURLDecodedBytes.js index f29a8179..84e9feb3 100644 --- a/lib/helpers/estimateDataURLDecodedBytes.js +++ b/lib/helpers/estimateDataURLDecodedBytes.js @@ -69,5 +69,16 @@ export default function estimateDataURLDecodedBytes(url) { return bytes > 0 ? bytes : 0; } - return Buffer.byteLength(body, 'utf8'); + if (typeof Buffer !== 'undefined' && typeof Buffer.byteLength === 'function') { + return Buffer.byteLength(body, 'utf8'); + } + + // Browser/worker fallback: use TextEncoder when available, else fall back to + // raw string length as an upper-bound heuristic. Both are safe for a DoS + // guard (over-counting only makes the check stricter for non-ASCII content). + if (typeof TextEncoder === 'function') { + return new TextEncoder().encode(body).byteLength; + } + + return body.length; } diff --git a/tests/unit/adapters/fetch.test.js b/tests/unit/adapters/fetch.test.js index f90a0b03..c82c7bb0 100644 --- a/tests/unit/adapters/fetch.test.js +++ b/tests/unit/adapters/fetch.test.js @@ -732,6 +732,171 @@ describe.runIf(typeof fetch === 'function')('supports fetch with nodejs', () => }); }); + describe('size limits (GHSA-777c-7fjr-54vf)', () => { + it('should reject an outbound body that exceeds maxBodyLength with ERR_BAD_REQUEST', async () => { + const server = await startHTTPServer( + (req, res) => { + res.end('ok'); + }, + { port: SERVER_PORT } + ); + + try { + await assert.rejects( + fetchAxios.post(`${LOCAL_SERVER_URL}/`, 'A'.repeat(2048), { + maxBodyLength: 1024, + }), + (err) => { + assert.strictEqual(err.code, 'ERR_BAD_REQUEST'); + assert.match(err.message, /Request body larger than maxBodyLength limit/); + return true; + } + ); + } finally { + await stopHTTPServer(server); + } + }); + + it('should reject a response whose Content-Length exceeds maxContentLength with ERR_BAD_RESPONSE', async () => { + const payload = 'A'.repeat(8 * 1024); + const server = await startHTTPServer( + (req, res) => { + res.setHeader('Content-Length', Buffer.byteLength(payload)); + res.end(payload); + }, + { port: SERVER_PORT } + ); + + try { + await assert.rejects( + fetchAxios.get(`${LOCAL_SERVER_URL}/`, { + maxContentLength: 1024, + }), + (err) => { + assert.strictEqual(err.code, 'ERR_BAD_RESPONSE'); + assert.match(err.message, /maxContentLength size of 1024 exceeded/); + return true; + } + ); + } finally { + await stopHTTPServer(server); + } + }); + + it('should reject a chunked response that exceeds maxContentLength during streaming', async () => { + const server = await startHTTPServer( + (req, res) => { + // Omit content-length so the cheap pre-check cannot fire; force + // the stream-based enforcement path. + res.setHeader('Transfer-Encoding', 'chunked'); + const chunk = 'B'.repeat(1024); + let sent = 0; + const writeNext = () => { + if (sent >= 8) { + return res.end(); + } + sent++; + res.write(chunk, writeNext); + }; + writeNext(); + }, + { port: SERVER_PORT } + ); + + try { + await assert.rejects( + fetchAxios.get(`${LOCAL_SERVER_URL}/`, { + maxContentLength: 512, + }), + (err) => { + assert.strictEqual(err.code, 'ERR_BAD_RESPONSE'); + assert.match(err.message, /maxContentLength size of 512 exceeded/); + return true; + } + ); + } finally { + await stopHTTPServer(server); + } + }); + + it('should reject a data: URL whose decoded size exceeds maxContentLength (base64)', async () => { + const payload = 'A'.repeat(4096); + const dataUrl = 'data:application/octet-stream;base64,' + Buffer.from(payload).toString('base64'); + + // Use a dedicated instance without baseURL — combineURLs would otherwise + // prepend baseURL to a data: URL and neutralise the pre-check. + const bareAxios = axios.create({ adapter: 'fetch' }); + + await assert.rejects( + bareAxios.get(dataUrl, { maxContentLength: 16 }), + (err) => { + assert.strictEqual(err.code, 'ERR_BAD_RESPONSE'); + assert.match(err.message, /maxContentLength size of 16 exceeded/); + return true; + } + ); + }); + + it('should reject a data: URL whose body size exceeds maxContentLength (non-base64)', async () => { + const dataUrl = 'data:text/plain,' + 'X'.repeat(4096); + + const bareAxios = axios.create({ adapter: 'fetch' }); + + await assert.rejects( + bareAxios.get(dataUrl, { maxContentLength: 16 }), + (err) => { + assert.strictEqual(err.code, 'ERR_BAD_RESPONSE'); + assert.match(err.message, /maxContentLength size of 16 exceeded/); + return true; + } + ); + }); + + it('should allow a response at or below maxContentLength', async () => { + const payload = 'ok'; + const server = await startHTTPServer( + (req, res) => { + res.end(payload); + }, + { port: SERVER_PORT } + ); + + try { + const { data } = await fetchAxios.get(`${LOCAL_SERVER_URL}/`, { + maxContentLength: 1024, + }); + assert.strictEqual(data, payload); + } finally { + await stopHTTPServer(server); + } + }); + + it('should allow a body at or below maxBodyLength', async () => { + const payload = 'hello'; + let received; + const server = await startHTTPServer( + (req, res) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + received = Buffer.concat(chunks).toString(); + res.end('ok'); + }); + }, + { port: SERVER_PORT } + ); + + try { + await fetchAxios.post(`${LOCAL_SERVER_URL}/`, payload, { + maxBodyLength: 1024, + }); + assert.strictEqual(received, payload); + } finally { + await stopHTTPServer(server); + } + }); + }); + describe('capability probe cleanup', () => { it('should cancel the ReadableStream created during the request stream probe', () => { // The fetch adapter factory probes for request-stream support by creating