214 lines
7.2 KiB
TypeScript
214 lines
7.2 KiB
TypeScript
import { lookup } from "node:dns/promises";
|
|
import { BlockList, isIP } from "node:net";
|
|
import type { LookupFunction } from "node:net";
|
|
import { Agent } from "undici";
|
|
|
|
import type { CustomJsxNetworkScope } from "../core";
|
|
import { CustomWidgetDomainError } from "./errors";
|
|
import { MAX_RESPONSE_BODY_BYTES } from "./response";
|
|
|
|
const RESERVED_HEADERS = new Set([
|
|
"authorization",
|
|
"connection",
|
|
"content-length",
|
|
"cookie",
|
|
"expect",
|
|
"forwarded",
|
|
"host",
|
|
"keep-alive",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"te",
|
|
"trailer",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
"via",
|
|
"x-forwarded-for",
|
|
"x-forwarded-host",
|
|
"x-forwarded-port",
|
|
"x-forwarded-proto",
|
|
]);
|
|
|
|
const blocked = new BlockList();
|
|
for (const [address, prefix] of [
|
|
["0.0.0.0", 8],
|
|
["100.64.0.0", 10],
|
|
["169.254.0.0", 16],
|
|
["192.0.0.0", 24],
|
|
["192.0.2.0", 24],
|
|
["198.18.0.0", 15],
|
|
["198.51.100.0", 24],
|
|
["203.0.113.0", 24],
|
|
["224.0.0.0", 4],
|
|
["240.0.0.0", 4],
|
|
] as const)
|
|
blocked.addSubnet(address, prefix, "ipv4");
|
|
blocked.addAddress("::", "ipv6");
|
|
for (const [address, prefix] of [
|
|
["64:ff9b::", 96],
|
|
["100::", 64],
|
|
["2001:db8::", 32],
|
|
["fe80::", 10],
|
|
["ff00::", 8],
|
|
] as const) {
|
|
blocked.addSubnet(address, prefix, "ipv6");
|
|
}
|
|
blocked.addAddress("fd00:ec2::254", "ipv6");
|
|
|
|
const privateAddresses = new BlockList();
|
|
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
|
|
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
|
|
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
|
|
privateAddresses.addSubnet("fc00::", 7, "ipv6");
|
|
const loopbackAddresses = new BlockList();
|
|
loopbackAddresses.addSubnet("127.0.0.0", 8, "ipv4");
|
|
loopbackAddresses.addAddress("::1", "ipv6");
|
|
|
|
type AddressFamily = 4 | 6;
|
|
export type ResolvedAddress = { address: string; family: AddressFamily };
|
|
export type HostResolver = (hostname: string) => Promise<ResolvedAddress[]>;
|
|
export interface ResolveHostOptions {
|
|
signal?: AbortSignal;
|
|
resolver?: HostResolver;
|
|
}
|
|
type AddressClass = "public" | "private" | "loopback" | "blocked";
|
|
const normalizeHostname = (value: string) =>
|
|
value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
|
|
const familyName = (family: AddressFamily) => (family === 4 ? "ipv4" : "ipv6");
|
|
|
|
export function classifyAddress(address: string): AddressClass {
|
|
const normalized = normalizeHostname(address).toLowerCase();
|
|
const family = isIP(normalized) as AddressFamily | 0;
|
|
if (!family || normalized.includes("::ffff:") || blocked.check(normalized, familyName(family))) return "blocked";
|
|
if (loopbackAddresses.check(normalized, familyName(family))) return "loopback";
|
|
if (privateAddresses.check(normalized, familyName(family))) return "private";
|
|
return "public";
|
|
}
|
|
|
|
export async function resolveAndValidateHost(
|
|
hostname: string,
|
|
scope: CustomJsxNetworkScope,
|
|
options: ResolveHostOptions = {},
|
|
): Promise<ResolvedAddress[]> {
|
|
const normalized = normalizeHostname(hostname);
|
|
const family = isIP(normalized) as AddressFamily | 0;
|
|
throwIfAborted(options.signal);
|
|
const addresses = family
|
|
? [{ address: normalized, family }]
|
|
: await abortable(
|
|
options.resolver?.(normalized) ??
|
|
(lookup(normalized, { all: true, verbatim: true }) as Promise<ResolvedAddress[]>),
|
|
options.signal,
|
|
);
|
|
throwIfAborted(options.signal);
|
|
if (!addresses.length)
|
|
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Target host did not resolve" });
|
|
for (const address of addresses) {
|
|
const classification = classifyAddress(address.address);
|
|
const allowed =
|
|
classification === "public" ||
|
|
(classification === "private" && scope !== "public") ||
|
|
(classification === "loopback" && scope === "loopback");
|
|
if (!allowed)
|
|
throw new CustomWidgetDomainError({
|
|
code: "FORBIDDEN",
|
|
message: `Target address is not allowed by the ${scope} network scope`,
|
|
});
|
|
}
|
|
return addresses;
|
|
}
|
|
|
|
function throwIfAborted(signal?: AbortSignal): void {
|
|
if (signal?.aborted) throw signal.reason ?? createAbortError();
|
|
}
|
|
|
|
function abortable<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
if (!signal) return operation;
|
|
if (signal.aborted) return Promise.reject(signal.reason ?? createAbortError());
|
|
|
|
return new Promise<T>((resolve, reject) => {
|
|
const onAbort = () => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
reject(signal.reason ?? createAbortError());
|
|
};
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
operation.then(
|
|
(value) => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
resolve(value);
|
|
},
|
|
(error: unknown) => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
reject(error);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
function createAbortError(): Error {
|
|
const error = new Error("Operation aborted");
|
|
error.name = "AbortError";
|
|
return error;
|
|
}
|
|
|
|
export function validateCustomWidgetUrl(value: string | URL): URL {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Invalid custom widget URL" });
|
|
}
|
|
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Only HTTP and HTTPS URLs are allowed" });
|
|
if (url.username || url.password)
|
|
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "URL credentials are not allowed" });
|
|
if (url.hash) throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "URL fragments are not allowed" });
|
|
return url;
|
|
}
|
|
|
|
export function resolveSameOriginTarget(baseValue: string, targetValue?: string | URL): URL {
|
|
const base = validateCustomWidgetUrl(baseValue);
|
|
const target = validateCustomWidgetUrl(targetValue ?? base);
|
|
if (target.origin !== base.origin)
|
|
throw new CustomWidgetDomainError({
|
|
code: "FORBIDDEN",
|
|
message: "Named requests must stay on the widget's origin",
|
|
});
|
|
return target;
|
|
}
|
|
|
|
export function assertSafeStaticHeaders(headers: Record<string, string> | undefined): void {
|
|
for (const name of Object.keys(headers ?? {})) {
|
|
const value = name.trim().toLowerCase();
|
|
if (
|
|
RESERVED_HEADERS.has(value) ||
|
|
value.startsWith("proxy-") ||
|
|
value.startsWith("sec-") ||
|
|
value.startsWith("x-forwarded-")
|
|
) {
|
|
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: `Header '${name}' is reserved` });
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createPinnedAgent(addresses: ResolvedAddress[], timeoutMs: number) {
|
|
const customLookup: LookupFunction = (_hostname, options, callback) => {
|
|
const family = options.family === 4 || options.family === 6 ? options.family : undefined;
|
|
const candidates = family ? addresses.filter((entry) => entry.family === family) : addresses;
|
|
const selected = candidates[0];
|
|
if (!selected) {
|
|
const error = new Error("No validated address for the requested family") as NodeJS.ErrnoException;
|
|
error.code = "ENOTFOUND";
|
|
callback(error, "", 0);
|
|
} else if (options.all) callback(null, candidates);
|
|
else callback(null, selected.address, selected.family);
|
|
};
|
|
return new Agent({
|
|
connect: { lookup: customLookup },
|
|
connectTimeout: timeoutMs,
|
|
headersTimeout: timeoutMs,
|
|
bodyTimeout: timeoutMs,
|
|
maxResponseSize: MAX_RESPONSE_BODY_BYTES,
|
|
});
|
|
}
|