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