mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
266 lines
7.8 KiB
TypeScript
266 lines
7.8 KiB
TypeScript
import { lookup, type LookupAddress, type LookupOptions } from "dns";
|
|
import { BlockList, isIP } from "net";
|
|
import { Agent, fetch as undiciFetch } from "undici";
|
|
import type { Dispatcher } from "undici-types";
|
|
import { getProxyAgent } from "./proxy-agent.js";
|
|
|
|
type DnsLookupFn = (
|
|
hostname: string,
|
|
options: LookupOptions,
|
|
callback: DnsLookupCallback,
|
|
) => void;
|
|
|
|
type DnsLookupCallback = (
|
|
err: NodeJS.ErrnoException | null,
|
|
address: string | LookupAddress[] | undefined,
|
|
family?: number,
|
|
) => void;
|
|
|
|
type LookupHookCallback = (
|
|
error: NodeJS.ErrnoException | Error | null,
|
|
address?: string | LookupAddress[],
|
|
family?: number,
|
|
) => void;
|
|
|
|
const blockedAddresses = new BlockList();
|
|
|
|
// Derived, not hand-duplicated: Node's BlockList matches addresses across
|
|
// families through their IPv4-mapped-IPv6 form regardless of which `type`
|
|
// you pass to check()/addSubnet() (see the addAddress('123.123.123.123') /
|
|
// check('::ffff:123.123.123.123') example on
|
|
// https://nodejs.org/api/net.html#class-netblocklist). So every IPv4 range
|
|
// below needs an "::ffff:<net>" mirror in the IPv6 list, or a spoofed
|
|
// literal like "::ffff:127.0.0.1" slips through unblocked. Generating the
|
|
// mirror from this list instead of maintaining two lists by hand means the
|
|
// two can't drift out of sync the way they did before.
|
|
const blockedIpv4Ranges = [
|
|
["0.0.0.0", 8],
|
|
["10.0.0.0", 8],
|
|
["100.64.0.0", 10], // CGNAT
|
|
["127.0.0.0", 8],
|
|
["169.254.0.0", 16], // link-local
|
|
["172.16.0.0", 12],
|
|
["192.168.0.0", 16],
|
|
["198.18.0.0", 15], // benchmarking
|
|
["224.0.0.0", 4], // multicast
|
|
["240.0.0.0", 4], // reserved
|
|
] as const;
|
|
|
|
for (const [network, prefix] of blockedIpv4Ranges) {
|
|
blockedAddresses.addSubnet(network, prefix, "ipv4");
|
|
blockedAddresses.addSubnet(`::ffff:${network}`, prefix + 96, "ipv6");
|
|
}
|
|
|
|
for (const [network, prefix] of [
|
|
["::", 128],
|
|
["::1", 128],
|
|
["fc00::", 7],
|
|
["fe80::", 10],
|
|
["ff00::", 8],
|
|
] as const) {
|
|
blockedAddresses.addSubnet(network, prefix, "ipv6");
|
|
}
|
|
|
|
export function isBlockedAddress(address: string): boolean {
|
|
const family = isIP(address);
|
|
return (
|
|
family === 0 ||
|
|
blockedAddresses.check(address, family === 4 ? "ipv4" : "ipv6")
|
|
);
|
|
}
|
|
|
|
// Extracted so the blocklist decision can be tested directly against a
|
|
// fake DNS resolver, instead of only through a real fetch()/Agent call —
|
|
// the actual bug here lived entirely in this callback, several layers
|
|
// below where undici's own "fetch failed" wrapping would otherwise hide it.
|
|
export function createDnsLookupHook(
|
|
dnsLookup: DnsLookupFn = lookup,
|
|
allowPrivate = false,
|
|
) {
|
|
return function lookupHook(
|
|
host: string,
|
|
lookupOptions: LookupOptions,
|
|
callback: LookupHookCallback,
|
|
): void {
|
|
const cleanHost = String(host ?? "").replace(/^[|]$/g, "");
|
|
const lookupAll = lookupOptions.all === true;
|
|
|
|
dnsLookup(
|
|
cleanHost,
|
|
{ ...lookupOptions, all: true, verbatim: true },
|
|
(error, addresses, family) => {
|
|
if (error) {
|
|
return callback(error, "", 0);
|
|
}
|
|
|
|
const addrs = Array.isArray(addresses)
|
|
? addresses
|
|
: addresses != null
|
|
? [{ address: addresses, family: family ?? isIP(addresses) }]
|
|
: undefined;
|
|
|
|
if (addrs === undefined) {
|
|
return callback(
|
|
new Error("DNS lookup returned invalid address"),
|
|
"",
|
|
0,
|
|
);
|
|
}
|
|
|
|
if (!addrs.length) {
|
|
return callback(
|
|
new Error("DNS resolution returned no addresses"),
|
|
"",
|
|
0,
|
|
);
|
|
}
|
|
|
|
if (
|
|
!allowPrivate &&
|
|
addrs.some(({ address }) => isBlockedAddress(address))
|
|
) {
|
|
return callback(
|
|
new Error("Private destinations are not allowed"),
|
|
"",
|
|
0,
|
|
);
|
|
}
|
|
|
|
if (lookupAll) {
|
|
return callback(null, addrs, 0);
|
|
}
|
|
|
|
const result = addrs[0];
|
|
const addr = String(result.address ?? "").replace(/^\[|\]$/g, "");
|
|
const fam =
|
|
typeof result.family === "number" ? result.family : isIP(addr);
|
|
|
|
if (!addr || isIP(addr) === 0) {
|
|
return callback(
|
|
new Error("DNS lookup returned invalid address"),
|
|
"",
|
|
0,
|
|
);
|
|
}
|
|
|
|
return callback(null, addr, fam);
|
|
},
|
|
);
|
|
};
|
|
}
|
|
|
|
export interface OutboundTlsOptions {
|
|
/** PEM bundle to trust instead of the system store (private CAs). */
|
|
ca?: string;
|
|
/**
|
|
* Skip certificate verification. Only for the one request that fetches a
|
|
* private CA's root by fingerprint, where the caller verifies the result.
|
|
*/
|
|
rejectUnauthorized?: boolean;
|
|
}
|
|
|
|
export async function readResponseTextLimited(
|
|
response: Response,
|
|
maxBytes: number,
|
|
): Promise<string> {
|
|
const declaredLength = Number(response.headers.get("content-length"));
|
|
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
await response.body?.cancel().catch(() => {});
|
|
throw new Error(`Response exceeds ${maxBytes} bytes`);
|
|
}
|
|
|
|
if (!response.body) return "";
|
|
const reader = response.body.getReader();
|
|
const chunks: Uint8Array[] = [];
|
|
let total = 0;
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
total += value.byteLength;
|
|
if (total > maxBytes) {
|
|
await reader.cancel().catch(() => {});
|
|
throw new Error(`Response exceeds ${maxBytes} bytes`);
|
|
}
|
|
chunks.push(value);
|
|
}
|
|
|
|
return Buffer.concat(chunks, total).toString("utf8");
|
|
}
|
|
|
|
/** undici's own default for both, kept as the floor. */
|
|
const DEFAULT_IDLE_TIMEOUT_MS = 300_000;
|
|
|
|
export function outboundIdleTimeout(timeoutMs?: number): number {
|
|
return Math.max(timeoutMs ?? 0, DEFAULT_IDLE_TIMEOUT_MS);
|
|
}
|
|
|
|
export async function safeOutboundFetch(
|
|
rawUrl: string,
|
|
options: RequestInit,
|
|
allowedPrivateHosts: readonly string[] = [],
|
|
tls: OutboundTlsOptions = {},
|
|
idleTimeoutMs?: number,
|
|
): Promise<Response> {
|
|
const url = new URL(rawUrl);
|
|
if (
|
|
!["http:", "https:"].includes(url.protocol) ||
|
|
url.username ||
|
|
url.password
|
|
) {
|
|
throw new Error("Invalid outbound URL");
|
|
}
|
|
|
|
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
const allowPrivate = allowedPrivateHosts.some(
|
|
(host) => host.trim().toLowerCase() === hostname.toLowerCase(),
|
|
);
|
|
if (!allowPrivate && isIP(hostname) && isBlockedAddress(hostname)) {
|
|
throw new Error("Private destinations are not allowed");
|
|
}
|
|
|
|
// An admin-allowlisted private host goes through the configured proxy,
|
|
// when there is one, the same way core's own outbound calls do. A public
|
|
// host never does: the proxy would resolve it, skipping the check above.
|
|
const proxy =
|
|
allowPrivate && !tls.ca && tls.rejectUnauthorized !== false
|
|
? getProxyAgent(url.toString())
|
|
: undefined;
|
|
if (proxy) {
|
|
return (await undiciFetch(url.toString(), {
|
|
...options,
|
|
dispatcher: proxy as unknown as Dispatcher,
|
|
redirect: options.redirect === "manual" ? "manual" : "error",
|
|
} as never)) as unknown as Response;
|
|
}
|
|
|
|
// A slow model can go quiet for minutes mid-stream. Past bodyTimeout undici
|
|
// kills the body with a bare "terminated".
|
|
const idle = outboundIdleTimeout(idleTimeoutMs);
|
|
const dispatcher = new Agent({
|
|
headersTimeout: idle,
|
|
bodyTimeout: idle,
|
|
connect: {
|
|
lookup: createDnsLookupHook(lookup, allowPrivate),
|
|
...(tls.ca ? { ca: tls.ca } : {}),
|
|
...(tls.rejectUnauthorized === false
|
|
? { rejectUnauthorized: false }
|
|
: {}),
|
|
},
|
|
});
|
|
|
|
try {
|
|
const response = await undiciFetch(url.toString(), {
|
|
...options,
|
|
dispatcher,
|
|
redirect: options.redirect === "manual" ? "manual" : "error",
|
|
});
|
|
// close() waits for the body, which the caller reads after we return.
|
|
dispatcher.close().catch(() => {});
|
|
return response;
|
|
} catch (error) {
|
|
await dispatcher.destroy().catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|