1import { promises as dns } from 'node:dns';
2import net from 'node:net';
3
4import { RunnerError } from './errors.js';
5
6export interface ResolvedDestination {
7 url: URL;
8 hostname: string;
9 address: string;
10 family: 4 | 6;
11 port: number;
12}
13
14export type LookupResult = { address: string; family: number };
15export type LookupAll = (hostname: string) => Promise<LookupResult[]>;
16
17export interface ResolvePublicUrlOptions {
18 allowedPorts?: ReadonlySet<number>;
19 lookupAll?: LookupAll;
20 allowPrivateForTests?: boolean;
21}
22
23const DEFAULT_ALLOWED_PORTS = new Set([80, 443]);
24
25const IPV4_BLOCKS: Array<[string, number]> = [
26 ['0.0.0.0', 8],
27 ['10.0.0.0', 8],
28 ['100.64.0.0', 10],
29 ['127.0.0.0', 8],
30 ['169.254.0.0', 16],
31 ['172.16.0.0', 12],
32 ['192.0.0.0', 24],
33 ['192.0.2.0', 24],
34 ['192.168.0.0', 16],
35 ['198.18.0.0', 15],
36 ['198.51.100.0', 24],
37 ['203.0.113.0', 24],
38 ['224.0.0.0', 4],
39 ['240.0.0.0', 4],
40];
41
42const IPV6_BLOCKS: Array<[string, number]> = [
43 ['::', 128],
44 ['::1', 128],
45 ['fc00::', 7],
46 ['fe80::', 10],
47 ['ff00::', 8],
48 ['2001::', 32],
49 ['2001:db8::', 32],
50 ['2002::', 16],
51];
52
53const blockList = new net.BlockList();
54for (const [network, prefix] of IPV4_BLOCKS) blockList.addSubnet(network, prefix, 'ipv4');
55for (const [network, prefix] of IPV6_BLOCKS) blockList.addSubnet(network, prefix, 'ipv6');
56
57const BLOCKED_HOSTS = new Set([
58 'localhost',
59 'localhost.localdomain',
60 'metadata.google.internal',
61 'instance-data',
62 'instance-data.ec2.internal',
63]);
64
65function unbracket(hostname: string): string {
66 if (hostname.startsWith('[') && hostname.endsWith(']')) return hostname.slice(1, -1);
67 return hostname;
68}
69
70function isBlockedHostname(hostname: string): boolean {
71 const host = unbracket(hostname).toLowerCase().replace(/\.$/, '');
72 return BLOCKED_HOSTS.has(host) || host.endsWith('.localhost') || host.endsWith('.local');
73}
74
75function isGlobalIpv6(address: string): boolean {
76
77
78 const firstGroup = address.split(':', 1)[0];
79 if (!firstGroup) return false;
80 const first = Number.parseInt(firstGroup, 16);
81 return Number.isFinite(first) && first >= 0x2000 && first <= 0x3fff;
82}
83
84export function isPublicAddress(address: string): boolean {
85 const normalized = unbracket(address).toLowerCase();
86 const family = net.isIP(normalized);
87 if (family === 4) return !blockList.check(normalized, 'ipv4');
88 if (family === 6) {
89 if (!isGlobalIpv6(normalized)) return false;
90 return !blockList.check(normalized, 'ipv6');
91 }
92 return false;
93}
94
95export function parseHttpUrl(
96 rawUrl: string,
97 allowedPorts: ReadonlySet<number> = DEFAULT_ALLOWED_PORTS,
98 allowPrivateForTests = false,
99): URL {
100 let url: URL;
101 try {
102 url = new URL(rawUrl);
103 } catch {
104 throw new RunnerError('UNSAFE_URL', 'URL is invalid.');
105 }
106
107 if (url.protocol !== 'http:' && url.protocol !== 'https:') {
108 throw new RunnerError('UNSAFE_URL', 'Only http:// and https:// URLs are allowed.');
109 }
110 if (url.username || url.password) {
111 throw new RunnerError('UNSAFE_URL', 'Credential-bearing URLs are not allowed.');
112 }
113 if (!url.hostname) throw new RunnerError('UNSAFE_URL', 'URL hostname is required.');
114 if (!allowPrivateForTests && isBlockedHostname(url.hostname)) throw new RunnerError('UNSAFE_URL', 'Private or local hostnames are not allowed.');
115
116 const host = unbracket(url.hostname);
117 const literalFamily = net.isIP(host);
118 if (!allowPrivateForTests && literalFamily !== 0 && !isPublicAddress(host)) {
119 throw new RunnerError('UNSAFE_URL', 'Private, loopback, link-local, metadata, multicast, or reserved IP targets are not allowed.');
120 }
121
122 const defaultPort = url.protocol === 'https:' ? 443 : 80;
123 const port = url.port ? Number(url.port) : defaultPort;
124 if (!Number.isInteger(port) || port < 1 || port > 65535 || !allowedPorts.has(port)) {
125 throw new RunnerError('UNSAFE_URL', `Destination port ${String(port)} is not allowed.`);
126 }
127
128 return url;
129}
130
131async function defaultLookupAll(hostname: string): Promise<LookupResult[]> {
132 const literalFamily = net.isIP(unbracket(hostname));
133 if (literalFamily !== 0) return [{ address: unbracket(hostname), family: literalFamily }];
134 return dns.lookup(hostname, { all: true, verbatim: true });
135}
136
137export async function resolvePublicUrl(rawUrl: string, options: ResolvePublicUrlOptions = {}): Promise<ResolvedDestination> {
138 const allowedPorts = options.allowedPorts ?? DEFAULT_ALLOWED_PORTS;
139 const url = parseHttpUrl(rawUrl, allowedPorts, options.allowPrivateForTests ?? false);
140 const hostname = unbracket(url.hostname).toLowerCase();
141 const port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80;
142 const lookupAll = options.lookupAll ?? defaultLookupAll;
143
144 let answers: LookupResult[];
145 try {
146 answers = await lookupAll(hostname);
147 } catch (error) {
148 throw new RunnerError('DNS_RESOLUTION_FAILED', `Could not resolve ${hostname}.`, {
149 cause: error instanceof Error ? error.message : String(error),
150 });
151 }
152
153 if (answers.length === 0) throw new RunnerError('DNS_RESOLUTION_FAILED', `No IP addresses resolved for ${hostname}.`);
154 if (answers.length > 32) throw new RunnerError('DNS_RESOLUTION_FAILED', `Too many IP addresses resolved for ${hostname}.`);
155
156 const normalized = answers.map(({ address, family }) => ({ address: unbracket(address), family }));
157 if (!options.allowPrivateForTests) {
158 const blocked = normalized.find(({ address }) => !isPublicAddress(address));
159 if (blocked) {
160
161
162 throw new RunnerError('UNSAFE_URL', `Resolved destination for ${hostname} includes a non-public IP address.`);
163 }
164 }
165
166 const selected = normalized.find(({ family }) => family === 4) ?? normalized[0];
167 if (!selected || (selected.family !== 4 && selected.family !== 6)) {
168 throw new RunnerError('DNS_RESOLUTION_FAILED', `Unsupported DNS result for ${hostname}.`);
169 }
170
171 return {
172 url,
173 hostname,
174 address: selected.address,
175 family: selected.family,
176 port,
177 };
178}