Files
WRNexusJS/packages/security/src/request.ts
T
2026-08-01 10:04:42 +05:30

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--;
}
};
}