From a9521f848268e712ab25f2948622ad770a647ae5 Mon Sep 17 00:00:00 2001 From: rushabdev Date: Tue, 30 Jun 2026 16:54:34 +0000 Subject: [PATCH] fix(feed-discovery): block additional reserved SSRF IP ranges Closes #88 Expand feed-discovery SSRF guard to reject additional non-public IPv4 and IPv6 address ranges: IPv4: - Documentation TEST-NET-1/2/3 (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) - IETF protocol assignments (192.0.0.0/24) - Benchmark (198.18.0.0/15) - Multicast (224.0.0.0/4) and reserved (240.0.0.0/4) IPv6: - Discard-only (100::/64) - Benchmark (2001:2::/48) - Documentation (2001:db8::/32) - Full link-local scope (fe80::/10, not just fe80::) - Multicast (ff00::/8) Adds regression tests for all newly blocked ranges. --- .../feed-discovery/src/feed-discovery.test.ts | 31 +++++++++++++++++++ plugins/feed-discovery/src/url-safety.ts | 24 ++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/plugins/feed-discovery/src/feed-discovery.test.ts b/plugins/feed-discovery/src/feed-discovery.test.ts index 4c28b96..97aca28 100644 --- a/plugins/feed-discovery/src/feed-discovery.test.ts +++ b/plugins/feed-discovery/src/feed-discovery.test.ts @@ -67,6 +67,37 @@ describe("site probing helpers", () => { await expect(assertSafeHttpUrl("file:///etc/passwd")).rejects.toThrow(/Unsupported URL protocol/); }); + it("blocks reserved and documentation IPv4 SSRF targets", async () => { + // TEST-NET-1 (192.0.2.0/24) + await expect(assertSafeHttpUrl("http://192.0.2.1/feed")).rejects.toThrow(/Blocked internal/); + // TEST-NET-2 (198.51.100.0/24) + await expect(assertSafeHttpUrl("http://198.51.100.1/feed")).rejects.toThrow(/Blocked internal/); + // TEST-NET-3 (203.0.113.0/24) + await expect(assertSafeHttpUrl("http://203.0.113.1/feed")).rejects.toThrow(/Blocked internal/); + // IETF protocol assignments (192.0.0.0/24) + await expect(assertSafeHttpUrl("http://192.0.0.1/feed")).rejects.toThrow(/Blocked internal/); + // Benchmark (198.18.0.0/15) + await expect(assertSafeHttpUrl("http://198.18.0.1/feed")).rejects.toThrow(/Blocked internal/); + await expect(assertSafeHttpUrl("http://198.19.255.255/feed")).rejects.toThrow(/Blocked internal/); + // Multicast (224.0.0.0/4) + await expect(assertSafeHttpUrl("http://224.0.0.1/feed")).rejects.toThrow(/Blocked internal/); + // Reserved (240.0.0.0/4) + await expect(assertSafeHttpUrl("http://240.0.0.1/feed")).rejects.toThrow(/Blocked internal/); + }); + + it("blocks reserved and documentation IPv6 SSRF targets", async () => { + // Discard-only (100::/64) + await expect(assertSafeHttpUrl("http://[100::]/feed")).rejects.toThrow(/Blocked internal/); + // Benchmark (2001:2::/48) + await expect(assertSafeHttpUrl("http://[2001:2::1]/feed")).rejects.toThrow(/Blocked internal/); + // Documentation (2001:db8::/32) + await expect(assertSafeHttpUrl("http://[2001:db8::1]/feed")).rejects.toThrow(/Blocked internal/); + // Multicast (ff00::/8) + await expect(assertSafeHttpUrl("http://[ff02::1]/feed")).rejects.toThrow(/Blocked internal/); + // Link-local (fe80::/10) — already blocked, verify extended range (feb0::) + await expect(assertSafeHttpUrl("http://[feb0::1]/feed")).rejects.toThrow(/Blocked internal/); + }); + it("ignores malformed configured candidate URLs", async () => { const provider = new WebCandidateFeedProbeProvider({ cacheTtlSeconds: 60, diff --git a/plugins/feed-discovery/src/url-safety.ts b/plugins/feed-discovery/src/url-safety.ts index f4c04f8..48700af 100644 --- a/plugins/feed-discovery/src/url-safety.ts +++ b/plugins/feed-discovery/src/url-safety.ts @@ -61,7 +61,17 @@ function isBlockedIp(value: string) { (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || - (a === 100 && b >= 64 && b <= 127) + (a === 100 && b >= 64 && b <= 127) || + // Documentation (TEST-NET-1/2/3) + (a === 192 && b === 0 && parts[2] === 2) || + (a === 198 && b === 51 && parts[2] === 100) || + (a === 203 && b === 0 && parts[2] === 113) || + // IETF protocol assignments (192.0.0.0/24) + (a === 192 && b === 0 && parts[2] === 0) || + // Benchmark (198.18.0.0/15) + (a === 198 && (b === 18 || b === 19)) || + // Multicast (224.0.0.0/4) and reserved (240.0.0.0/4) + a >= 224 ); } @@ -92,7 +102,17 @@ function isBlockedIp(value: string) { normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || - normalized.startsWith("fe80") + normalized.startsWith("fe80") || + // Discard-only (100::/64) + normalized.startsWith("100::") || + // Benchmark (2001:2::/48) + normalized.startsWith("2001:2:") || + // Documentation (2001:db8::/32) + normalized.startsWith("2001:db8:") || + // Link-local scoped (fe80::/10 — covers fe80 through febf) + /^fe[89ab]/.test(normalized) || + // Multicast (ff00::/8) + normalized.startsWith("ff") ); }