Skip to content

Commit 304365f

Browse files
Chummy-debugissue-solver-botAbelOsaretin
authored
fix: resolve issue #507 (#607)
Co-authored-by: issue-solver-bot <issue-solver-bot@users.noreply.github.com> Co-authored-by: Abel Osaretin <76490851+AbelOsaretin@users.noreply.github.com>
1 parent ac61002 commit 304365f

3 files changed

Lines changed: 192 additions & 0 deletions

File tree

src/__tests__/webhooks.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,48 @@ describe("webhooks routes", () => {
4747
.expect(400);
4848
});
4949

50+
it("POST /api/webhooks — rejects non-http(s) protocols", async () => {
51+
await request(app)
52+
.post("/api/webhooks")
53+
.send({ url: "ftp://example.com/hook", secret: "my-super-secret-key" })
54+
.expect(400);
55+
});
56+
57+
it("POST /api/webhooks — rejects strings merely starting with 'http'", async () => {
58+
await request(app)
59+
.post("/api/webhooks")
60+
.send({ url: "httpanything", secret: "my-super-secret-key" })
61+
.expect(400);
62+
});
63+
64+
it("POST /api/webhooks — rejects loopback URLs", async () => {
65+
await request(app)
66+
.post("/api/webhooks")
67+
.send({ url: "http://127.0.0.1/hook", secret: "my-super-secret-key" })
68+
.expect(400);
69+
});
70+
71+
it("POST /api/webhooks — rejects private IP URLs", async () => {
72+
await request(app)
73+
.post("/api/webhooks")
74+
.send({ url: "http://10.0.0.1/hook", secret: "my-super-secret-key" })
75+
.expect(400);
76+
});
77+
78+
it("POST /api/webhooks — rejects link-local / metadata URLs", async () => {
79+
await request(app)
80+
.post("/api/webhooks")
81+
.send({ url: "http://169.254.169.254/latest/meta-data", secret: "my-super-secret-key" })
82+
.expect(400);
83+
});
84+
85+
it("POST /api/webhooks — rejects IPv6 loopback URLs", async () => {
86+
await request(app)
87+
.post("/api/webhooks")
88+
.send({ url: "http://[::1]/hook", secret: "my-super-secret-key" })
89+
.expect(400);
90+
});
91+
5092
it("GET /api/webhooks — lists registered webhooks without secrets", async () => {
5193
await request(app)
5294
.post("/api/webhooks")

src/lib/webhooks.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createHmac } from "crypto";
22
import { lookup } from "dns/promises";
3+
import { isIP } from "net";
34
import { BlockList, isIP } from "net";
45
import { withRetry } from "./retry";
56
import { logger } from "./logger";
@@ -15,6 +16,112 @@ export interface WebhookConfig {
1516

1617
const webhooks = new Map<string, WebhookConfig>();
1718

19+
/**
20+
* Validate that a webhook URL is a well-formed http/https URL whose resolved
21+
* hostname does not point at private, loopback, link-local, or metadata
22+
* address ranges. This is a defense-in-depth SSRF guard: even though the
23+
* webhook endpoint is admin-only, we never want the server to make outbound
24+
* requests to internal infrastructure on behalf of a caller.
25+
*
26+
* Returns the normalized URL string on success, or throws an Error with a
27+
* human-readable message describing why the URL was rejected.
28+
*/
29+
export async function validateWebhookUrl(rawUrl: string): Promise<string> {
30+
let parsed: URL;
31+
try {
32+
parsed = new URL(rawUrl);
33+
} catch {
34+
throw new Error("url must be a valid http/https URL");
35+
}
36+
37+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
38+
throw new Error("url must be a valid http/https URL");
39+
}
40+
41+
if (!parsed.hostname) {
42+
throw new Error("url must include a hostname");
43+
}
44+
45+
// Resolve the hostname to every address it currently maps to and reject if
46+
// any of them is in a forbidden range. We check all addresses because a
47+
// hostname could resolve to a mix of public and private IPs.
48+
let addresses: string[];
49+
try {
50+
const result = await lookup(parsed.hostname, { all: true });
51+
addresses = result.map((entry) => entry.address);
52+
} catch {
53+
throw new Error("url hostname could not be resolved");
54+
}
55+
56+
if (addresses.length === 0) {
57+
throw new Error("url hostname could not be resolved");
58+
}
59+
60+
for (const address of addresses) {
61+
if (isForbiddenAddress(address)) {
62+
throw new Error("url must not point to a private, loopback, link-local, or metadata address");
63+
}
64+
}
65+
66+
return parsed.toString();
67+
}
68+
69+
function ipv4ToNumber(ip: string): number {
70+
const parts = ip.split(".").map(Number);
71+
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
72+
}
73+
74+
function ipv4InRange(ipNum: number, network: string, prefix: number): boolean {
75+
const networkNum = ipv4ToNumber(network);
76+
const mask = prefix === 0 ? 0 : ~((1 << (32 - prefix)) - 1) >>> 0;
77+
return (ipNum & mask) === (networkNum & mask);
78+
}
79+
80+
function isForbiddenIPv4(ip: string): boolean {
81+
const num = ipv4ToNumber(ip);
82+
const ranges: Array<[string, number]> = [
83+
["0.0.0.0", 8], // "this" network
84+
["10.0.0.0", 8], // private
85+
["100.64.0.0", 10], // CGNAT
86+
["127.0.0.0", 8], // loopback
87+
["169.254.0.0", 16], // link-local (incl. cloud metadata)
88+
["172.16.0.0", 12], // private
89+
["192.0.0.0", 24], // IETF protocol assignments
90+
["192.0.2.0", 24], // TEST-NET-1
91+
["192.168.0.0", 16], // private
92+
["198.18.0.0", 15], // benchmarking
93+
["198.51.100.0", 24], // TEST-NET-2
94+
["203.0.113.0", 24], // TEST-NET-3
95+
["224.0.0.0", 4], // multicast
96+
["240.0.0.0", 4], // reserved
97+
];
98+
return ranges.some(([network, prefix]) => ipv4InRange(num, network, prefix));
99+
}
100+
101+
function isForbiddenIPv6(ip: string): boolean {
102+
const lower = ip.toLowerCase();
103+
// IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — check the embedded IPv4.
104+
const mappedMatch = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
105+
if (mappedMatch) {
106+
return isForbiddenIPv4(mappedMatch[1]);
107+
}
108+
// Normalize the common "::" forms for prefix matching.
109+
if (lower === "::" || lower === "::1") return true; // unspecified / loopback
110+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique local fc00::/7
111+
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true; // link-local fe80::/10
112+
if (lower.startsWith("fec") || lower.startsWith("fed") || lower.startsWith("fee") || lower.startsWith("fef")) return true; // site-local fec0::/10
113+
if (lower.startsWith("ff")) return true; // multicast ff00::/8
114+
if (lower.startsWith("2001:db8")) return true; // documentation 2001:db8::/32
115+
return false;
116+
}
117+
118+
function isForbiddenAddress(address: string): boolean {
119+
const family = isIP(address);
120+
if (family === 4) return isForbiddenIPv4(address);
121+
if (family === 6) return isForbiddenIPv6(address);
122+
// Unknown address family — treat as forbidden to be safe.
123+
return true;
124+
}
18125
const BLOCKED_IPS = new BlockList();
19126
BLOCKED_IPS.addSubnet("0.0.0.0", 8);
20127
BLOCKED_IPS.addSubnet("10.0.0.0", 8);
@@ -114,6 +221,7 @@ export async function validateWebhookUrl(rawUrl: string): Promise<URL> {
114221
}
115222

116223
async function deliverOnce(url: string, body: string, signature: string): Promise<void> {
224+
// Re-validate at delivery time in case DNS changed after registration.
117225
// Re-validate immediately before sending to avoid DNS rebinding attacks after registration.
118226
await validateWebhookUrl(url);
119227
const response = await fetch(url, {

src/routes/webhooks.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,48 @@ import { badRequest } from "../middleware/errors";
1010

1111
const router = Router();
1212

13+
/**
14+
* POST /api/webhooks
15+
* Body: { url, secret, max_retries?, retry_delay_ms? }
16+
* Registers a new webhook endpoint.
17+
*/
18+
router.post("/", async (req: Request, res: Response) => {
19+
const { url, secret, max_retries, retry_delay_ms } = req.body as {
20+
url?: unknown;
21+
secret?: unknown;
22+
max_retries?: unknown;
23+
retry_delay_ms?: unknown;
24+
};
25+
26+
if (typeof url !== "string") {
27+
throw badRequest("url must be a valid http/https URL");
28+
}
29+
if (typeof secret !== "string" || secret.length < 16) {
30+
throw badRequest("secret must be a string of at least 16 characters");
31+
}
32+
33+
let validatedUrl: string;
34+
try {
35+
validatedUrl = await validateWebhookUrl(url);
36+
} catch (err) {
37+
throw badRequest(err instanceof Error ? err.message : "url must be a valid http/https URL");
38+
}
39+
40+
const maxRetries =
41+
typeof max_retries === "number" && max_retries >= 0 ? Math.floor(max_retries) : 3;
42+
const retryDelay =
43+
typeof retry_delay_ms === "number" && retry_delay_ms >= 0
44+
? Math.floor(retry_delay_ms)
45+
: 2000;
46+
47+
const wh = registerWebhook(validatedUrl, secret, maxRetries, retryDelay);
48+
res.status(201).json({
49+
id: wh.id,
50+
url: wh.url,
51+
max_retries: wh.max_retries,
52+
retry_delay_ms: wh.retry_delay_ms,
53+
created_at: wh.created_at,
54+
});
1355
router.post("/", async (req: Request, res: Response, next: NextFunction) => {
1456
try {
1557
const { url, secret, max_retries, retry_delay_ms } = req.body as {

0 commit comments

Comments
 (0)