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:
+103
-2
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user