Blame · Line-by-line history
webhook-ssrf-redirect.test.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.
| 6088927 | 1 | /** |
| 2 | * Webhook delivery SSRF: redirect hops and the DNS layer. | |
| 3 | * | |
| 4 | * Two independent bypasses of the guard that webhook delivery relies on: | |
| 5 | * | |
| 6 | * 1. `fetch()` defaults to `redirect: "follow"`. assertPublicUrl only ever saw | |
| 7 | * the URL the user registered, so a genuinely public endpoint could answer | |
| 8 | * 302 → http://169.254.169.254/latest/meta-data/ and fetch would follow it. | |
| 9 | * One redirect defeated the entire guard. | |
| 10 | * | |
| 11 | * 2. resolvesToPrivate() — the DNS layer that catches a public-looking | |
| 12 | * hostname pointing at a private address — existed, was fully unit-tested, | |
| 13 | * and had ZERO production callers. Only src/__tests__/ssrf-guard.test.ts | |
| 14 | * imported it. `evil.example.com A 127.0.0.1` passed the literal check. | |
| 15 | * | |
| 16 | * These assertions are structural. A functional test would need to bind a | |
| 17 | * redirecting server on localhost, but the guard deliberately default-allows | |
| 18 | * private addresses under the test env, so the harness would have to disable | |
| 19 | * the very behaviour under test. The guard's own address-classification logic | |
| 20 | * is already covered functionally by ssrf-guard.test.ts. | |
| 21 | */ | |
| 22 | ||
| 23 | import { describe, expect, it } from "bun:test"; | |
| 24 | import { readFileSync } from "fs"; | |
| 25 | ||
| 26 | const SRC = readFileSync("src/lib/webhook-delivery.ts", "utf8"); | |
| 27 | const fn = SRC.slice( | |
| 28 | SRC.indexOf("async function deliverFollowingRedirects"), | |
| 29 | SRC.indexOf("// ---", SRC.indexOf("async function deliverFollowingRedirects")) | |
| 30 | ); | |
| 31 | ||
| 32 | describe("redirects cannot escape the guard", () => { | |
| 33 | it("never lets fetch follow redirects itself", () => { | |
| 34 | expect(fn).toContain('redirect: "manual"'); | |
| 35 | }); | |
| 36 | ||
| 37 | it("no bare fetch(row.url) with default redirect handling remains", () => { | |
| 38 | // The original call site. If this reappears the guard is bypassable again. | |
| 39 | expect(SRC).not.toMatch(/await fetch\(row\.url, \{\s*\n\s*method: "POST"/); | |
| 40 | }); | |
| 41 | ||
| 42 | it("re-validates the URL on every hop, not just the first", () => { | |
| 43 | // assertPublicUrl must be inside the loop. | |
| 44 | const loopStart = fn.indexOf("for (let hop"); | |
| 45 | const guardCall = fn.indexOf("assertPublicUrl(url)"); | |
| 46 | expect(loopStart).toBeGreaterThan(-1); | |
| 47 | expect(guardCall).toBeGreaterThan(loopStart); | |
| 48 | }); | |
| 49 | ||
| 50 | it("bounds the hop count so a redirect cycle cannot spin", () => { | |
| 51 | expect(SRC).toContain("MAX_REDIRECTS"); | |
| 52 | expect(fn).toContain("hop <= MAX_REDIRECTS"); | |
| 53 | expect(fn).toContain("more than ${MAX_REDIRECTS} redirects"); | |
| 54 | }); | |
| 55 | ||
| 56 | it("resolves a relative Location against the current hop", () => { | |
| 57 | // `Location: /latest/meta-data/` on a redirect must not be treated as | |
| 58 | // an absolute URL or silently dropped. | |
| 59 | expect(fn).toContain("new URL(location, url)"); | |
| 60 | }); | |
| 61 | ||
| 62 | it("treats a 3xx with no Location as a terminal response", () => { | |
| 63 | expect(fn).toContain("if (!location) return { status: res.status }"); | |
| 64 | }); | |
| 65 | }); | |
| 66 | ||
| 67 | describe("the DNS layer is actually wired in", () => { | |
| 68 | it("webhook delivery calls resolvesToPrivate", () => { | |
| 69 | // It had zero production callers before this. | |
| 70 | expect(SRC).toContain("resolvesToPrivate"); | |
| 71 | expect(fn).toContain("await resolvesToPrivate(guard.url.hostname)"); | |
| 72 | }); | |
| 73 | ||
| 74 | it("checks DNS before issuing the request", () => { | |
| 75 | const dns = fn.indexOf("resolvesToPrivate"); | |
| 76 | const req = fn.indexOf("await fetch(url"); | |
| 77 | expect(dns).toBeLessThan(req); | |
| 78 | }); | |
| 79 | ||
| 80 | it("honours the same allow-private escape hatch as assertPublicUrl", () => { | |
| 81 | // Without this the DNS layer blocks 127.0.0.1 under SSRF_ALLOW_PRIVATE=1 | |
| 82 | // and in the test env, breaking suites that point webhooks at a local | |
| 83 | // Bun.serve() — which is exactly what it did on the first attempt. | |
| 84 | expect(fn).toContain("ssrfPrivateAllowed()"); | |
| 85 | expect(fn).toContain("enforcing &&"); | |
| 86 | }); | |
| 87 | }); | |
| 88 | ||
| 89 | describe("blocked deliveries are reported as blocked", () => { | |
| 90 | it("surfaces an SSRF block distinctly from an HTTP error", () => { | |
| 91 | expect(SRC).toContain("blockedReason"); | |
| 92 | expect(SRC).toContain("(SSRF protection)"); | |
| 93 | }); | |
| 94 | }); |