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';
|
} 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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', () => {
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user