release: WRNexusJS 0.7.0
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
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--;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user