Skip to content

Commit 94f9640

Browse files
authored
Block private IPv6 webhook URLs (#28)
1 parent c0e65b6 commit 94f9640

3 files changed

Lines changed: 70 additions & 19 deletions

File tree

lib/url-guard.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/* ---- SSRF guard: never POST to internal/loopback/link-local addresses ---- */
2+
export function isInternalUrl(raw: string): boolean {
3+
let u: URL;
4+
try { u = new URL(raw); } catch { return true; }
5+
if (u.protocol !== "https:" && u.protocol !== "http:") return true;
6+
if (process.env.NODE_ENV === "production" && u.protocol !== "https:") return true;
7+
const h = u.hostname.toLowerCase();
8+
if (h === "localhost" || h.endsWith(".localhost") || h === "metadata.google.internal") return true;
9+
if (h === "0.0.0.0" || h === "::1" || h === "[::1]") return true;
10+
const ipv6 = h.replace(/^\[|\]$/g, "");
11+
if (ipv6.includes(":")) {
12+
const mapped = ipv6.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
13+
if (mapped) {
14+
const high = Number.parseInt(mapped[1]!, 16);
15+
const low = Number.parseInt(mapped[2]!, 16);
16+
const a = high >> 8;
17+
const b = high & 255;
18+
const c = low >> 8;
19+
if (
20+
a === 127 ||
21+
a === 10 ||
22+
a === 0 ||
23+
(a === 172 && b >= 16 && b <= 31) ||
24+
(a === 192 && b === 168) ||
25+
(a === 169 && b === 254)
26+
) return true;
27+
if (a || b || c || low) return false;
28+
}
29+
if (
30+
ipv6 === "::" ||
31+
ipv6 === "::1" ||
32+
ipv6.startsWith("fe80:") ||
33+
ipv6.startsWith("fc") ||
34+
ipv6.startsWith("fd") ||
35+
ipv6.startsWith("::ffff:10.") ||
36+
ipv6.startsWith("::ffff:127.") ||
37+
ipv6.startsWith("::ffff:192.168.") ||
38+
/^::ffff:172\.(1[6-9]|2\d|3[01])\./.test(ipv6) ||
39+
ipv6.startsWith("::ffff:169.254.")
40+
) return true;
41+
}
42+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
43+
if (m) {
44+
const [a, b] = [Number(m[1]), Number(m[2])];
45+
if (a === 127 || a === 10 || a === 0) return true;
46+
if (a === 172 && b >= 16 && b <= 31) return true;
47+
if (a === 192 && b === 168) return true;
48+
if (a === 169 && b === 254) return true;
49+
}
50+
return false;
51+
}

lib/webhooks.ts

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import crypto from "node:crypto";
22
import { db, activeDomainWebhooks } from "./db";
3+
import { isInternalUrl } from "./url-guard";
34

45
const TOLERANCE = 300; // seconds
56

@@ -33,25 +34,7 @@ export function newSecret(prefix = "whsec_") {
3334
return prefix + crypto.randomBytes(24).toString("base64url");
3435
}
3536

36-
/* ---- SSRF guard: never POST to internal/loopback/link-local addresses ---- */
37-
export function isInternalUrl(raw: string): boolean {
38-
let u: URL;
39-
try { u = new URL(raw); } catch { return true; }
40-
if (u.protocol !== "https:" && u.protocol !== "http:") return true;
41-
if (process.env.NODE_ENV === "production" && u.protocol !== "https:") return true;
42-
const h = u.hostname.toLowerCase();
43-
if (h === "localhost" || h.endsWith(".localhost") || h === "metadata.google.internal") return true;
44-
if (h === "0.0.0.0" || h === "::1" || h === "[::1]") return true;
45-
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
46-
if (m) {
47-
const [a, b] = [Number(m[1]), Number(m[2])];
48-
if (a === 127 || a === 10 || a === 0) return true;
49-
if (a === 172 && b >= 16 && b <= 31) return true;
50-
if (a === 192 && b === 168) return true;
51-
if (a === 169 && b === 254) return true;
52-
}
53-
return false;
54-
}
37+
export { isInternalUrl };
5538

5639
/* ---- per-domain outbound delivery (best-effort, no owner server needed) ---- */
5740
/**

tests/url-guard.test.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { isInternalUrl } from "../lib/url-guard.ts";
5+
6+
test("SSRF guard blocks private IPv6 webhook targets", () => {
7+
assert.equal(isInternalUrl("http://[::1]/hook"), true);
8+
assert.equal(isInternalUrl("http://[fe80::1]/hook"), true);
9+
assert.equal(isInternalUrl("http://[fc00::1]/hook"), true);
10+
assert.equal(isInternalUrl("http://[fd12:3456::1]/hook"), true);
11+
assert.equal(isInternalUrl("http://[::ffff:192.168.1.10]/hook"), true);
12+
});
13+
14+
test("SSRF guard allows public IPv6 webhook targets", () => {
15+
assert.equal(isInternalUrl("https://[2606:4700:4700::1111]/hook"), false);
16+
assert.equal(isInternalUrl("https://[::ffff:8.8.8.8]/hook"), false);
17+
});

0 commit comments

Comments
 (0)