Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

ssrf-guard.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

ssrf-guard.tsBlame295 lines · 1 contributor
bc519aeClaude1/**
2 * SSRF guard — P1 security hardening (BUILD_BIBLE §7).
3 *
4 * Server-side fetches of user-controlled URLs (webhook deliveries, repo
5 * mirror upstreams) must not be allowed to reach private / internal
6 * address space: cloud metadata endpoints (169.254.169.254), localhost
7 * services, RFC1918 LANs, etc.
8 *
9 * Primary defence is the *synchronous literal check* — `isPrivateAddress()`
10 * recognises hostname conventions (localhost, .local, .internal), every
11 * private/reserved IPv4 range (including decimal/hex/octal single-integer
12 * encodings like `http://2130706433/`), and the private IPv6 forms
13 * (::1, ::, fc00::/7, fe80::/10, IPv4-mapped ::ffff:x.x.x.x).
14 *
15 * `resolvesToPrivate()` adds a best-effort DNS layer: if a public-looking
16 * hostname resolves to a private address it can be blocked too. Resolution
17 * failures never block — the subsequent connect would fail anyway.
18 *
19 * Escape hatches (read at call time, never at module load):
20 * - SSRF_ALLOW_PRIVATE=1 — disable blocking entirely (local dev /
21 * self-hosted setups that legitimately mirror internal git servers).
22 * - test env default-allow — when NODE_ENV/BUN_ENV is "test" (and not
23 * production), blocking is OFF unless SSRF_ENFORCE_IN_TEST=1. Existing
24 * suites spin up Bun.serve() on localhost and must keep passing; the
25 * ssrf-guard suite opts back in via SSRF_ENFORCE_IN_TEST=1.
26 */
27
28// ---------------------------------------------------------------------------
29// IPv4 parsing — inet_aton-compatible (1–4 parts, decimal/hex/octal).
30// ---------------------------------------------------------------------------
31
32/** Parse one IPv4 component: decimal, 0x-hex, or 0-prefixed octal. */
33function parseIpv4Part(s: string): number | null {
34 if (!/^(0x[0-9a-f]+|[0-9]+)$/.test(s)) return null;
35 let v: number;
36 if (s.startsWith("0x")) {
37 v = parseInt(s.slice(2), 16);
38 } else if (s.length > 1 && s.startsWith("0")) {
39 // Leading zero → octal (inet_aton semantics, so 0177.0.0.1 = 127.0.0.1).
40 if (!/^[0-7]+$/.test(s)) return null;
41 v = parseInt(s, 8);
42 } else {
43 v = parseInt(s, 10);
44 }
45 return Number.isFinite(v) ? v : null;
46}
47
48/**
49 * Parse an IPv4 literal into a 32-bit unsigned integer, accepting the
50 * 1/2/3/4-part forms inet_aton accepts (so `2130706433`, `0x7f000001`,
51 * `0177.0.0.1`, and `127.1` all map to 127.0.0.1). Returns null when the
52 * string is not an IPv4 literal (e.g. a normal hostname).
53 */
54export function parseIpv4(host: string): number | null {
55 const parts = host.split(".");
56 if (parts.length < 1 || parts.length > 4) return null;
57 const nums: number[] = [];
58 for (const p of parts) {
59 const v = parseIpv4Part(p);
60 if (v === null) return null;
61 nums.push(v);
62 }
63
64 const n = nums.length;
65 if (n === 1) {
66 if (nums[0]! > 0xffffffff) return null;
67 return nums[0]! >>> 0;
68 }
69 // All but the last part are single octets; the last fills the remainder.
70 for (let i = 0; i < n - 1; i++) {
71 if (nums[i]! > 0xff) return null;
72 }
73 const lastMax = [0, 0, 0xffffff, 0xffff, 0xff][n]!;
74 if (nums[n - 1]! > lastMax) return null;
75
76 let prefix = 0;
77 for (let i = 0; i < n - 1; i++) {
78 prefix = prefix * 256 + nums[i]!;
79 }
80 return (prefix * 2 ** ((5 - n) * 8) + nums[n - 1]!) >>> 0;
81}
82
83/** True when a 32-bit IPv4 address falls in a private/reserved range. */
84function isPrivateIpv4(ip: number): boolean {
85 const a = (ip >>> 24) & 0xff;
86 const b = (ip >>> 16) & 0xff;
87 if (a === 127) return true; // 127.0.0.0/8 loopback
88 if (a === 10) return true; // 10.0.0.0/8
89 if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
90 if (a === 192 && b === 168) return true; // 192.168.0.0/16
91 if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local/metadata
92 if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
93 if (a === 0) return true; // 0.0.0.0/8 "this network"
94 if (ip === 0xffffffff) return true; // 255.255.255.255 broadcast
95 return false;
96}
97
98// ---------------------------------------------------------------------------
99// IPv6 parsing — 8×16-bit words, with :: compression and embedded IPv4.
100// ---------------------------------------------------------------------------
101
102/** Convert colon-separated groups to 16-bit words (embedded IPv4 → 2 words). */
103function groupsToWords(groups: string[]): number[] | null {
104 const words: number[] = [];
105 for (let i = 0; i < groups.length; i++) {
106 const g = groups[i]!;
107 if (g.includes(".")) {
108 // Embedded IPv4 — only valid in the final position.
109 if (i !== groups.length - 1) return null;
110 const v4 = parseIpv4(g);
111 if (v4 === null) return null;
112 words.push((v4 >>> 16) & 0xffff, v4 & 0xffff);
113 } else {
114 if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
115 words.push(parseInt(g, 16));
116 }
117 }
118 return words;
119}
120
121/** Parse an IPv6 literal into 8 words, or null if it isn't one. */
122function parseIpv6(input: string): number[] | null {
123 let h = input;
124 const pct = h.indexOf("%"); // strip zone id (fe80::1%eth0)
125 if (pct !== -1) h = h.slice(0, pct);
126 if (h.length === 0) return null;
127
128 const dbl = h.split("::");
129 if (dbl.length > 2) return null;
130
131 if (dbl.length === 2) {
132 const head = dbl[0] === "" ? [] : dbl[0]!.split(":");
133 const tail = dbl[1] === "" ? [] : dbl[1]!.split(":");
134 const headW = groupsToWords(head);
135 const tailW = groupsToWords(tail);
136 if (!headW || !tailW) return null;
137 const fill = 8 - headW.length - tailW.length;
138 if (fill < 1) return null;
139 return [...headW, ...new Array(fill).fill(0), ...tailW];
140 }
141
142 const words = groupsToWords(h.split(":"));
143 if (!words || words.length !== 8) return null;
144 return words;
145}
146
147/** True when an IPv6 literal is loopback/unspecified/ULA/link-local/mapped-private. */
148function isPrivateIpv6(host: string): boolean {
149 const w = parseIpv6(host);
150 if (!w) return false; // not a parseable literal — connect will fail anyway
151 if (w.every((x) => x === 0)) return true; // :: unspecified
152 if (w.slice(0, 7).every((x) => x === 0) && w[7] === 1) return true; // ::1
153 if ((w[0]! & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA
154 if ((w[0]! & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
155 // IPv4-mapped ::ffff:a.b.c.d (and the bare ::a.b.c.d compat form).
156 if (w[0] === 0 && w[1] === 0 && w[2] === 0 && w[3] === 0 && w[4] === 0) {
157 if (w[5] === 0xffff || (w[5] === 0 && (w[6] !== 0 || w[7]! > 1))) {
158 const ip = ((w[6]! << 16) | w[7]!) >>> 0;
159 return isPrivateIpv4(ip);
160 }
161 }
162 return false;
163}
164
165// ---------------------------------------------------------------------------
166// Public surface
167// ---------------------------------------------------------------------------
168
169/**
170 * Pure, synchronous check: is `host` a private/internal address or a
171 * hostname that conventionally maps to one? Accepts bare hostnames, IPv4
172 * literals in any inet_aton encoding, and IPv6 literals (with or without
173 * the URL bracket form `[::1]`).
174 */
175export function isPrivateAddress(host: string): boolean {
176 if (!host) return true; // empty host — nothing legitimate to reach
177 let h = host.trim().toLowerCase();
178 if (h.endsWith(".")) h = h.slice(0, -1); // trailing-dot FQDN form
179 if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
180 if (h.length === 0) return true;
181
182 // Hostname conventions.
183 if (h === "localhost" || h.endsWith(".localhost")) return true;
184 if (h.endsWith(".local") || h.endsWith(".internal")) return true;
185
186 // IPv6 literal.
187 if (h.includes(":")) return isPrivateIpv6(h);
188
189 // IPv4 literal (any encoding). Non-literals fall through as public —
190 // the optional DNS layer (resolvesToPrivate) covers resolved names.
191 const v4 = parseIpv4(h);
192 if (v4 !== null) return isPrivateIpv4(v4);
193
194 return false;
195}
196
197/**
198 * Should private addresses be allowed right now? Read at call time, never
199 * cached at module load.
200 *
201 * - `SSRF_ALLOW_PRIVATE=1` → always allow (local dev / self-hosted).
202 * - Test env (NODE_ENV/BUN_ENV "test", and NOT production) → allow unless
203 * `SSRF_ENFORCE_IN_TEST=1`. Belt-and-braces mirrors rate-limit.ts: a
204 * leaked test env var in a production container must not drop the guard.
205 */
206export function ssrfPrivateAllowed(): boolean {
207 if (process.env.SSRF_ALLOW_PRIVATE === "1") return true;
208 const isTestEnv =
209 process.env.NODE_ENV !== "production" &&
210 (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test");
211 return isTestEnv && process.env.SSRF_ENFORCE_IN_TEST !== "1";
212}
213
214export type PublicUrlResult =
215 | { ok: true; url: URL }
216 | { ok: false; reason: string };
217
218export interface PublicUrlOptions {
219 /** Explicit override; when omitted, env policy (ssrfPrivateAllowed) applies. */
220 allowPrivate?: boolean;
221 /** Allowed schemes incl. trailing colon. Default: http/https. */
222 schemes?: string[];
223}
224
225const DEFAULT_SCHEMES = ["http:", "https:"];
226
227/**
228 * Validate a user-supplied URL for server-side fetching. Requires an
229 * http/https scheme (callers may widen via `opts.schemes`, e.g. git: for
230 * mirrors) and rejects private hosts per `isPrivateAddress()`.
231 *
232 * Embedded credentials (https://user:pw@host/) are deliberately allowed —
233 * mirror upstreams legitimately use them; callers strip them from logs.
234 *
235 * Never throws.
236 */
237export function assertPublicUrl(
238 raw: string,
239 opts?: PublicUrlOptions
240): PublicUrlResult {
241 let url: URL;
242 try {
243 url = new URL(raw);
244 } catch {
245 return { ok: false, reason: "invalid URL" };
246 }
247
248 const schemes = opts?.schemes ?? DEFAULT_SCHEMES;
249 if (!schemes.includes(url.protocol)) {
250 return { ok: false, reason: `unsupported scheme ${url.protocol.replace(/:$/, "")}` };
251 }
252
253 const allowPrivate = opts?.allowPrivate ?? ssrfPrivateAllowed();
254 if (allowPrivate) return { ok: true, url };
255
256 if (!url.hostname) {
257 return { ok: false, reason: "missing host" };
258 }
259 if (isPrivateAddress(url.hostname)) {
260 return { ok: false, reason: "private address" };
261 }
262 return { ok: true, url };
263}
264
265// ---------------------------------------------------------------------------
266// Optional async DNS layer — best-effort, injectable for tests.
267// ---------------------------------------------------------------------------
268
269export type DnsResolver = (
270 host: string
271) => Promise<Array<{ address: string }>>;
272
273async function defaultResolver(host: string) {
274 const { lookup } = await import("node:dns/promises");
275 return lookup(host, { all: true });
276}
277
278/**
279 * True when `host` is a private literal OR resolves (A/AAAA) to a private
280 * address. Best-effort by design: if resolution fails or times out we do
281 * NOT block — the literal check above is the primary defence and a
282 * non-resolving host can't be fetched anyway.
283 */
284export async function resolvesToPrivate(
285 host: string,
286 resolver: DnsResolver = defaultResolver
287): Promise<boolean> {
288 if (isPrivateAddress(host)) return true;
289 try {
290 const records = await resolver(host);
291 return records.some((r) => isPrivateAddress(r.address));
292 } catch {
293 return false;
294 }
295}