release: WRNexusJS 0.7.0

This commit is contained in:
2026-08-01 10:04:42 +05:30
parent c54144f2e4
commit 87507edf59
207 changed files with 12607 additions and 679 deletions
+160
View File
@@ -0,0 +1,160 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { SecurityError } from "./errors.ts";
import { validateUrl, type SafeUrlPolicy } from "./url.ts";
export interface SafeFetchOptions extends RequestInit, SafeUrlPolicy {
timeoutMs?: number;
maxRedirects?: number;
maxResponseBytes?: number;
blockPrivateNetworks?: boolean;
/** Forward Authorization, Cookie, and Proxy-Authorization across origin-changing redirects. Defaults to false. */
forwardSensitiveHeaders?: boolean;
resolver?: (hostname: string) => Promise<string[]>;
}
function isPrivateIpv4(address: string): boolean {
const parts = address.split(".").map(Number);
if (
parts.length !== 4 ||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
) {
return false;
}
const [a, b] = parts;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b! >= 16 && b! <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b! >= 64 && b! <= 127) ||
a! >= 224
);
}
function isPrivateIpv6(address: string): boolean {
const normalized = address.toLowerCase().split("%")[0]!;
const mappedIpv4 = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(normalized)?.[1];
if (mappedIpv4) return isPrivateIpv4(mappedIpv4);
return (
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe8") ||
normalized.startsWith("fe9") ||
normalized.startsWith("fea") ||
normalized.startsWith("feb") ||
normalized.startsWith("ff")
);
}
export function isPrivateAddress(address: string): boolean {
const version = isIP(address);
return version === 4 ? isPrivateIpv4(address) : version === 6 ? isPrivateIpv6(address) : false;
}
async function defaultResolver(hostname: string): Promise<string[]> {
if (isIP(hostname)) return [hostname];
return (await lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address);
}
async function assertPublicHost(url: URL, options: SafeFetchOptions): Promise<void> {
if (options.blockPrivateNetworks === false) return;
const addresses = await (options.resolver ?? defaultResolver)(url.hostname);
if (!addresses.length) {
throw new SecurityError("WRN-SEC-SSRF-DNS", `Host '${url.hostname}' did not resolve.`);
}
const blocked = addresses.find(isPrivateAddress);
if (blocked) {
throw new SecurityError(
"WRN-SEC-SSRF-PRIVATE",
`Host '${url.hostname}' resolves to blocked address '${blocked}'.`,
403,
);
}
}
export async function safeFetch(
input: string | URL,
options: SafeFetchOptions = {},
): Promise<Response> {
const timeoutMs = options.timeoutMs ?? 10_000;
const maxRedirects = options.maxRedirects ?? 3;
const maxResponseBytes = options.maxResponseBytes ?? 5 * 1024 * 1024;
const controller = new AbortController();
const externalSignal = options.signal;
const abort = () => controller.abort(externalSignal?.reason);
externalSignal?.addEventListener("abort", abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error("WRNexus safeFetch timeout")),
timeoutMs,
);
const init: RequestInit = { ...options, signal: controller.signal, redirect: "manual" };
delete (init as Record<string, unknown>).timeoutMs;
delete (init as Record<string, unknown>).maxRedirects;
delete (init as Record<string, unknown>).maxResponseBytes;
delete (init as Record<string, unknown>).blockPrivateNetworks;
delete (init as Record<string, unknown>).resolver;
delete (init as Record<string, unknown>).forwardSensitiveHeaders;
delete (init as Record<string, unknown>).base;
delete (init as Record<string, unknown>).allowRelative;
delete (init as Record<string, unknown>).allowedProtocols;
delete (init as Record<string, unknown>).allowedHosts;
delete (init as Record<string, unknown>).blockedHosts;
delete (init as Record<string, unknown>).allowCredentials;
delete (init as Record<string, unknown>).allowDataImages;
try {
let current = validateUrl(input, {
...options,
allowRelative: false,
allowedProtocols: options.allowedProtocols ?? ["https:"],
});
let previousOrigin = current.origin;
for (let redirect = 0; ; redirect++) {
await assertPublicHost(current, options);
const requestInit: RequestInit = { ...init };
if (!options.forwardSensitiveHeaders && current.origin !== previousOrigin) {
const headers = new Headers(init.headers);
headers.delete("authorization");
headers.delete("cookie");
headers.delete("proxy-authorization");
requestInit.headers = headers;
}
const response = await fetch(current, requestInit);
if (response.status >= 300 && response.status < 400 && response.headers.has("location")) {
if (redirect >= maxRedirects) {
throw new SecurityError("WRN-SEC-SSRF-REDIRECT", "Too many redirects.", 502);
}
previousOrigin = current.origin;
current = validateUrl(new URL(response.headers.get("location")!, current), {
...options,
allowRelative: false,
allowedProtocols: options.allowedProtocols ?? ["https:"],
});
continue;
}
const contentLength = Number(response.headers.get("content-length") ?? "0");
if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) {
throw new SecurityError("WRN-SEC-SSRF-SIZE", "Remote response is too large.", 502);
}
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > maxResponseBytes) {
throw new SecurityError("WRN-SEC-SSRF-SIZE", "Remote response is too large.", 502);
}
return new Response(bytes, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
} finally {
clearTimeout(timer);
externalSignal?.removeEventListener("abort", abort);
}
}