From 9df80b73e26e23114b57e11b7532bfb05d450ec3 Mon Sep 17 00:00:00 2001 From: User Date: Tue, 24 Feb 2026 10:47:11 +0800 Subject: [PATCH] fix: allow RFC2544 benchmark range (198.18.0.0/15) through SSRF filter Telegram's API and file servers resolve to IPs in the 198.18.0.0/15 range (RFC 2544 benchmarking range). The SSRF filter was blocking these addresses because ipaddr.js classifies them as 'reserved', and the filter also had an explicit RFC2544_BENCHMARK_PREFIX check that blocked them unconditionally. Fix: exempt 198.18.0.0/15 from the 'reserved' range block in isBlockedSpecialUseIpv4Address(). Other 'reserved' ranges (TEST-NET-2, TEST-NET-3, documentation prefixes) remain blocked. The explicit RFC2544_BENCHMARK_PREFIX check is repurposed as the exemption guard. Closes #24973 --- src/infra/net/fetch-guard.ssrf.test.ts | 16 +++++++++++++++- src/infra/net/ssrf.pinning.test.ts | 10 +++++++++- src/infra/net/ssrf.test.ts | 17 +++++++++++------ src/shared/net/ip.ts | 16 +++++++++++++--- 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index de0140d76..7d5b4090a 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -37,13 +37,27 @@ describe("fetchWithSsrFGuard hardening", () => { const fetchImpl = vi.fn(); await expect( fetchWithSsrFGuard({ - url: "http://198.18.0.1:8080/internal", + url: "http://198.51.100.1:8080/internal", fetchImpl, }), ).rejects.toThrow(/private|internal|blocked/i); expect(fetchImpl).not.toHaveBeenCalled(); }); + it("allows RFC2544 benchmark range IPv4 literal URLs (Telegram)", async () => { + const lookupFn = vi.fn(async () => [ + { address: "198.18.0.153", family: 4 }, + ]) as unknown as LookupFn; + const fetchImpl = vi.fn().mockResolvedValueOnce(new Response("ok", { status: 200 })); + // Should not throw — 198.18.x.x is allowed now + const result = await fetchWithSsrFGuard({ + url: "http://198.18.0.153/file", + fetchImpl, + lookupFn, + }); + expect(result.response.status).toBe(200); + }); + it("blocks redirect chains that hop to private hosts", async () => { const lookupFn = vi.fn(async () => [ { address: "93.184.216.34", family: 4 }, diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 660b8b6df..7ae0242c0 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -51,12 +51,20 @@ describe("ssrf pinning", () => { it.each([ { name: "RFC1918 private address", address: "10.0.0.8" }, - { name: "RFC2544 benchmarking range", address: "198.18.0.1" }, + { name: "TEST-NET-2 reserved range", address: "198.51.100.1" }, ])("rejects blocked DNS results: $name", async ({ address }) => { const lookup = vi.fn(async () => [{ address, family: 4 }]) as unknown as LookupFn; await expect(resolvePinnedHostname("example.com", lookup)).rejects.toThrow(/private|internal/i); }); + it("allows RFC2544 benchmark range addresses (used by Telegram)", async () => { + const lookup = vi.fn(async () => [ + { address: "198.18.0.153", family: 4 }, + ]) as unknown as LookupFn; + const pinned = await resolvePinnedHostname("api.telegram.org", lookup); + expect(pinned.addresses).toContain("198.18.0.153"); + }); + it("falls back for non-matching hostnames", async () => { const fallback = vi.fn((host: string, options?: unknown, callback?: unknown) => { const cb = typeof options === "function" ? options : (callback as () => void); diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 5d8fe8f66..1bb2d77db 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -3,8 +3,6 @@ import { normalizeFingerprint } from "../tls/fingerprint.js"; import { isBlockedHostnameOrIp, isPrivateIpAddress } from "./ssrf.js"; const privateIpCases = [ - "198.18.0.1", - "198.19.255.254", "198.51.100.42", "203.0.113.10", "192.0.0.8", @@ -15,7 +13,6 @@ const privateIpCases = [ "240.0.0.1", "255.255.255.255", "::ffff:127.0.0.1", - "::ffff:198.18.0.1", "64:ff9b::198.51.100.42", "0:0:0:0:0:ffff:7f00:1", "0000:0000:0000:0000:0000:ffff:7f00:0001", @@ -32,7 +29,6 @@ const privateIpCases = [ "2002:a9fe:a9fe::", "2001:0000:0:0:0:0:80ff:fefe", "2001:0000:0:0:0:0:3f57:fefe", - "2002:c612:0001::", "::", "::1", "fe80::1%lo0", @@ -45,13 +41,18 @@ const privateIpCases = [ const publicIpCases = [ "93.184.216.34", "198.17.255.255", + "198.18.0.1", + "198.18.0.153", + "198.19.255.254", "198.20.0.1", + "2002:c612:0001::", "198.51.99.1", "198.51.101.1", "203.0.112.1", "203.0.114.1", "223.255.255.255", "2606:4700:4700::1111", + "::ffff:198.18.0.1", "2001:db8::1", "64:ff9b::8.8.8.8", "64:ff9b:1::8.8.8.8", @@ -119,9 +120,13 @@ describe("isBlockedHostnameOrIp", () => { expect(isBlockedHostnameOrIp("2001:db8::1")).toBe(false); }); - it("blocks IPv4 special-use ranges but allows adjacent public ranges", () => { - expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(true); + it("allows RFC2544 benchmark range (used by Telegram) but blocks adjacent special-use ranges", () => { + expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(false); + expect(isBlockedHostnameOrIp("198.18.0.153")).toBe(false); + expect(isBlockedHostnameOrIp("198.19.255.254")).toBe(false); expect(isBlockedHostnameOrIp("198.20.0.1")).toBe(false); + expect(isBlockedHostnameOrIp("198.51.100.1")).toBe(true); + expect(isBlockedHostnameOrIp("203.0.113.1")).toBe(true); }); it("blocks legacy IPv4 literal representations", () => { diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts index 21770a20e..a6b84ddd0 100644 --- a/src/shared/net/ip.ts +++ b/src/shared/net/ip.ts @@ -28,6 +28,12 @@ const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set([ "linkLocal", "uniqueLocal", ]); +/** + * RFC 2544 benchmark range (198.18.0.0/15). Originally reserved for network + * device benchmarking, but in practice used by real services — notably + * Telegram's API/file servers resolve to addresses in this block. We + * therefore exempt it from the SSRF block list. + */ const RFC2544_BENCHMARK_PREFIX: [ipaddr.IPv4, number] = [ipaddr.IPv4.parse("198.18.0.0"), 15]; const EMBEDDED_IPV4_SENTINEL_RULES: Array<{ @@ -248,9 +254,13 @@ export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean { } export function isBlockedSpecialUseIpv4Address(address: ipaddr.IPv4): boolean { - return ( - BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()) || address.match(RFC2544_BENCHMARK_PREFIX) - ); + const range = address.range(); + if (range === "reserved" && address.match(RFC2544_BENCHMARK_PREFIX)) { + // 198.18.0.0/15 is classified as "reserved" by ipaddr.js but is used by + // real public services (e.g. Telegram API). Allow it through. + return false; + } + return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(range); } function decodeIpv4FromHextets(high: number, low: number): ipaddr.IPv4 {