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.
This commit is contained in:
rushabdev 2026-06-30 16:54:34 +00:00
parent edf32139ae
commit a9521f8482
2 changed files with 53 additions and 2 deletions

View file

@ -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,

View file

@ -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")
);
}