2
0
mirror of https://github.com/tenrok/axios.git synced 2026-06-17 19:21:29 +03:00

fix: fetch adaptor is not enforcing max body or content length (#10795)

This commit is contained in:
Jay
2026-04-22 18:54:12 +02:00
committed by GitHub
parent fba7fcc44e
commit e5540dcafe
3 changed files with 280 additions and 3 deletions
+103 -2
View File
@@ -11,6 +11,7 @@ import {
} from '../helpers/progressEventReducer.js'; } from '../helpers/progressEventReducer.js';
import resolveConfig from '../helpers/resolveConfig.js'; import resolveConfig from '../helpers/resolveConfig.js';
import settle from '../core/settle.js'; import settle from '../core/settle.js';
import estimateDataURLDecodedBytes from '../helpers/estimateDataURLDecodedBytes.js';
const DEFAULT_CHUNK_SIZE = 64 * 1024; const DEFAULT_CHUNK_SIZE = 64 * 1024;
@@ -163,8 +164,13 @@ const factory = (env) => {
headers, headers,
withCredentials = 'same-origin', withCredentials = 'same-origin',
fetchOptions, fetchOptions,
maxContentLength,
maxBodyLength,
} = resolveConfig(config); } = resolveConfig(config);
const hasMaxContentLength = utils.isNumber(maxContentLength) && maxContentLength > -1;
const hasMaxBodyLength = utils.isNumber(maxBodyLength) && maxBodyLength > -1;
let _fetch = envFetch || fetch; let _fetch = envFetch || fetch;
responseType = responseType ? (responseType + '').toLowerCase() : 'text'; responseType = responseType ? (responseType + '').toLowerCase() : 'text';
@@ -186,6 +192,41 @@ const factory = (env) => {
let requestContentLength; let requestContentLength;
try { 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 ( if (
onUploadProgress && onUploadProgress &&
supportsRequestStream && supportsRequestStream &&
@@ -252,10 +293,27 @@ const factory = (env) => {
? _fetch(request, fetchOptions) ? _fetch(request, fetchOptions)
: _fetch(url, resolvedOptions)); : _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 = const isStreamResponse =
supportsResponseStream && (responseType === 'stream' || responseType === 'response'); supportsResponseStream && (responseType === 'stream' || responseType === 'response');
if (supportsResponseStream && (onDownloadProgress || (isStreamResponse && unsubscribe))) { if (
supportsResponseStream &&
(onDownloadProgress || hasMaxContentLength || (isStreamResponse && unsubscribe))
) {
const options = {}; const options = {};
['status', 'statusText', 'headers'].forEach((prop) => { ['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( response = new Response(
trackStream(response.body, DEFAULT_CHUNK_SIZE, onProgress, () => { trackStream(response.body, DEFAULT_CHUNK_SIZE, onChunkProgress, () => {
flush && flush(); flush && flush();
unsubscribe && unsubscribe(); unsubscribe && unsubscribe();
}), }),
@@ -288,6 +362,33 @@ const factory = (env) => {
config 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(); !isStreamResponse && unsubscribe && unsubscribe();
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
@@ -69,5 +69,16 @@ export default function estimateDataURLDecodedBytes(url) {
return bytes > 0 ? bytes : 0; return bytes > 0 ? bytes : 0;
} }
if (typeof Buffer !== 'undefined' && typeof Buffer.byteLength === 'function') {
return Buffer.byteLength(body, 'utf8'); 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;
} }
+165
View File
@@ -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', () => { describe('capability probe cleanup', () => {
it('should cancel the ReadableStream created during the request stream probe', () => { it('should cancel the ReadableStream created during the request stream probe', () => {
// The fetch adapter factory probes for request-stream support by creating // The fetch adapter factory probes for request-stream support by creating