1
21import { lookup } from "node:dns/promises";
22
23export class SsrfError extends Error {
24 constructor(
25 public readonly code: "SSRF_BLOCKED",
26 message: string
27 ) {
28 super(message);
29 this.name = "SsrfError";
30 }
31}
32
33
34const BLOCKED_HOSTNAMES = new Set([
35 "localhost",
36 "ip6-localhost",
37 "ip4.local",
38 "ip6.local",
39 "metadata",
40 "metadata.google.internal",
41 "instance-data",
42 "instance-data.rhcloud.com"
43]);
44
45function hostIsBlockedName(hostname: string): boolean {
46 const h = hostname.toLowerCase();
47 if (BLOCKED_HOSTNAMES.has(h)) return true;
48 if (h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".localhost")) return true;
49
50 if (h === "metadata.google.internal" || h.endsWith(".compute.internal")) return true;
51 return false;
52}
53
54
59export function isPrivateIp(ip: string): boolean {
60 const v6 = expandIpv6(ip);
61 if (v6) return isPrivateV6(v6);
62 const parts = ip.split(".").map((n) => Number(n));
63 if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
64 const [a, b] = parts as [number, number, number, number];
65 if (a === 0) return true;
66 if (a === 10) return true;
67 if (a === 127) return true;
68 if (a === 169 && b === 254) return true;
69 if (a === 172 && b >= 16 && b <= 31) return true;
70 if (a === 192 && b === 168) return true;
71 return false;
72}
73
74
75function expandIpv6(ip: string): number[] | null {
76 if (!ip.includes(":")) return null;
77
78 const raw = ip.split("%")[0]!;
79
80 const halves = raw.split("::");
81 if (halves.length > 2) return null;
82 const left = halves[0] ? halves[0]!.split(":") : [];
83 const right = halves.length === 2 && halves[1] ? halves[1]!.split(":") : [];
84 const groups = [...left, ...right];
85 if (halves.length === 2 && groups.length > 7) return null;
86 const fill = 8 - groups.length;
87 const all = halves.length === 2 ? [...left, ...Array(fill).fill("0"), ...right] : groups;
88 if (all.length !== 8) return null;
89 const nums = all.map((g) => {
90 if (!/^[0-9a-f]{0,4}$/i.test(g)) return NaN;
91 return g === "" ? 0 : parseInt(g, 16);
92 });
93 if (nums.some((n) => Number.isNaN(n))) return null;
94 return nums;
95}
96
97function isPrivateV6(groups: number[]): boolean {
98 const first = groups[0]!;
99
100 if (groups.every((g) => g === 0)) return true;
101
102 if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true;
103
104 if ((first & 0xfe00) === 0xfc00) return true;
105
106 if ((first & 0xffc0) === 0xfe80) return true;
107
108 if (first === 0x64 && groups[1] === 0xff9b) return true;
109 return false;
110}
111
112
117export function assertPublicUrlString(url: string): void {
118 let u: URL;
119 try {
120 u = new URL(url);
121 } catch {
122 throw new SsrfError("SSRF_BLOCKED", "invalid URL");
123 }
124 if (u.protocol !== "https:") {
125 throw new SsrfError("SSRF_BLOCKED", `URL must be https (got ${u.protocol})`);
126 }
127 const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase();
128 if (hostIsBlockedName(host)) {
129 throw new SsrfError("SSRF_BLOCKED", `blocked hostname: ${host}`);
130 }
131
132 if (!host.includes(":") && /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) {
133 if (isPrivateIp(host)) throw new SsrfError("SSRF_BLOCKED", `private IP: ${host}`);
134 } else if (host.includes(":")) {
135 const v6 = expandIpv6(host);
136 if (v6 && isPrivateV6(v6)) throw new SsrfError("SSRF_BLOCKED", `private IPv6: ${host}`);
137 }
138}
139
140
145export async function assertPublicUrl(url: string): Promise<void> {
146 assertPublicUrlString(url);
147 let u: URL;
148 try {
149 u = new URL(url);
150 } catch {
151 throw new SsrfError("SSRF_BLOCKED", "invalid URL");
152 }
153 const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase();
154 if (hostIsBlockedName(host)) throw new SsrfError("SSRF_BLOCKED", `blocked hostname: ${host}`);
155
156 if (!host.includes(":") && /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) {
157 if (isPrivateIp(host)) throw new SsrfError("SSRF_BLOCKED", `private IP: ${host}`);
158 return;
159 }
160 if (host.includes(":")) {
161 const v6 = expandIpv6(host);
162 if (v6 && isPrivateV6(v6)) throw new SsrfError("SSRF_BLOCKED", `private IPv6: ${host}`);
163 return;
164 }
165
166 let addrs: { address: string }[];
167 try {
168 addrs = await lookup(host, { all: true });
169 } catch {
170
171 throw new SsrfError("SSRF_BLOCKED", `cannot resolve host: ${host}`);
172 }
173 if (addrs.length === 0) throw new SsrfError("SSRF_BLOCKED", `no addresses for: ${host}`);
174 for (const a of addrs) {
175 if (isPrivateIp(a.address)) {
176 throw new SsrfError("SSRF_BLOCKED", `host ${host} resolves to private IP ${a.address}`);
177 }
178 }
179}
180