@whatwg-node/server
Version:
Fetch API compliant HTTP Server adapter
90 lines (89 loc) • 3.9 kB
JavaScript
import { HTTPError } from './useErrorHandling.js';
export class RequestBodyTooLargeError extends HTTPError {
name = 'RequestBodyTooLargeError';
constructor(message = 'Request body too large') {
super(413, message);
}
}
export class InvalidContentLengthError extends HTTPError {
name = 'InvalidContentLengthError';
constructor(message = 'Content-Length header is invalid.') {
super(400, message);
}
}
// Only a single non-negative integer is a valid Content-Length. Anything else (non-numeric,
// negative, or multiple comma-joined values as seen in request-smuggling attempts) is rejected
// outright instead of being allowed to silently skip this check.
const CONTENT_LENGTH_RE = /^\d+$/;
function defaultResponseFromError(error, fetchAPI) {
return new fetchAPI.Response(error.message, {
status: error.status,
headers: error.headers,
});
}
/**
* Limits the size of incoming HTTP request bodies.
*
* Requests whose `Content-Length` exceeds `limit` (or whose `Content-Length` is invalid) are
* rejected early via `endResponse`. Bodies are also counted while streaming (via
* `TransformStream`), including when `Content-Length` is missing, wrong, or overridden — so a
* short `Content-Length` cannot bypass the limit. `Transfer-Encoding` and `Content-Encoding`
* cases are covered by the same byte counter.
*
* When using `useContentEncoding`, register it **before** this plugin so the byte counter sees
* decoded bytes. `onRequest` hooks run in `plugins` array order; the reverse order would count
* compressed size and then allow decompression past `limit`.
*
* To disable limiting, omit this plugin from the adapter.
*/
export function useLimitRequestBodySize(limit, options) {
if (!Number.isFinite(limit) || limit < 0) {
throw new TypeError(`useLimitRequestBodySize: expected a finite non-negative limit, got ${String(limit)}`);
}
const responseFromError = options?.responseFromError ?? defaultResponseFromError;
return {
onRequest({ request, setRequest, fetchAPI, endResponse }) {
const contentLength = request.headers.get('content-length');
if (contentLength != null) {
if (!CONTENT_LENGTH_RE.test(contentLength)) {
endResponse(responseFromError(new InvalidContentLengthError(), fetchAPI));
return;
}
if (Number(contentLength) > limit) {
endResponse(responseFromError(new RequestBodyTooLargeError(), fetchAPI));
return;
}
}
if (!request.body) {
return;
}
let bytesRead = 0;
const limitedBody = request.body.pipeThrough(new fetchAPI.TransformStream({
transform(chunk, controller) {
bytesRead += chunk.byteLength;
if (bytesRead > limit) {
controller.error(new RequestBodyTooLargeError());
return;
}
controller.enqueue(chunk);
},
}));
setRequest(new fetchAPI.Request(request.url, {
body: limitedBody,
cache: request.cache,
credentials: request.credentials,
headers: request.headers,
integrity: request.integrity,
keepalive: request.keepalive,
method: request.method,
mode: request.mode,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
signal: request.signal,
// @ts-expect-error duplex is required for streamed bodies in some runtimes
duplex: 'half',
}));
},
};
}