80 lines
3.0 KiB
TypeScript
80 lines
3.0 KiB
TypeScript
import type { Context, Middleware, RequestLimitsConfig } from "@wrnexus/core";
|
|
|
|
export type RequestHardeningOptions = RequestLimitsConfig;
|
|
|
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
|
|
function headerBytes(headers: Headers): { count: number; bytes: number } {
|
|
let count = 0;
|
|
let bytes = 0;
|
|
headers.forEach((value, name) => {
|
|
count++;
|
|
bytes += new TextEncoder().encode(`${name}:${value}\r\n`).byteLength;
|
|
});
|
|
return { count, bytes };
|
|
}
|
|
|
|
function hostAllowed(host: string, rules: string[]): boolean {
|
|
const normalized = host.toLowerCase().split(":")[0]!;
|
|
return rules.some((rule) => {
|
|
const candidate = rule.toLowerCase();
|
|
return candidate.startsWith("*.")
|
|
? normalized.endsWith(candidate.slice(1))
|
|
: normalized === candidate;
|
|
});
|
|
}
|
|
|
|
function reject(status: number, message: string): Response {
|
|
return new Response(message, {
|
|
status,
|
|
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
|
|
});
|
|
}
|
|
|
|
export function requestHardening(options: RequestHardeningOptions = {}): Middleware {
|
|
const maxUrlLength = options.maxUrlLength ?? 8_192;
|
|
const maxHeaderCount = options.maxHeaderCount ?? 100;
|
|
const maxHeaderBytes = options.maxHeaderBytes ?? 32 * 1024;
|
|
const maxQueryParameters = options.maxQueryParameters ?? 200;
|
|
const maxBodyBytes = options.maxBodyBytes ?? 10 * 1024 * 1024;
|
|
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
const maxConcurrent = options.maxConcurrent ?? 1_000;
|
|
let concurrent = 0;
|
|
|
|
return async (ctx: Context, next) => {
|
|
if (ctx.req.url.length > maxUrlLength) return reject(414, "URI Too Long");
|
|
const measured = headerBytes(ctx.req.headers);
|
|
if (measured.count > maxHeaderCount || measured.bytes > maxHeaderBytes) {
|
|
return reject(431, "Request Header Fields Too Large");
|
|
}
|
|
if ([...ctx.url.searchParams].length > maxQueryParameters) {
|
|
return reject(400, "Too many query parameters");
|
|
}
|
|
const contentLength = Number(ctx.req.headers.get("content-length") ?? "0");
|
|
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
|
return reject(413, "Payload Too Large");
|
|
}
|
|
if (options.trustedHosts?.length) {
|
|
const host = ctx.req.headers.get("host") ?? ctx.url.host;
|
|
if (!hostAllowed(host, options.trustedHosts)) return reject(421, "Misdirected Request");
|
|
}
|
|
if (options.fetchMetadata !== false && !SAFE_METHODS.has(ctx.req.method.toUpperCase())) {
|
|
const site = ctx.req.headers.get("sec-fetch-site");
|
|
if (site === "cross-site") return reject(403, "Cross-site request denied");
|
|
}
|
|
if (concurrent >= maxConcurrent) return reject(503, "Server Busy");
|
|
|
|
concurrent++;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
const timeout = new Promise<Response>((resolve) => {
|
|
timer = setTimeout(() => resolve(reject(504, "Request Timeout")), timeoutMs);
|
|
});
|
|
return await Promise.race([Promise.resolve(next()), timeout]);
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
concurrent--;
|
|
}
|
|
};
|
|
}
|