diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a732763f..7a7cc7560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. - Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 7d5b4090a..a03afba32 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -37,23 +37,19 @@ describe("fetchWithSsrFGuard hardening", () => { const fetchImpl = vi.fn(); await expect( fetchWithSsrFGuard({ - url: "http://198.51.100.1:8080/internal", + url: "http://198.18.0.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; + it("allows RFC2544 benchmark range IPv4 literal URLs when explicitly opted in", async () => { 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, + policy: { allowRfc2544BenchmarkRange: true }, }); expect(result.response.status).toBe(200); }); diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 7ae0242c0..19d61bdae 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -51,17 +51,26 @@ 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 () => { + it("allows RFC2544 benchmark range addresses only when policy explicitly opts in", 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); + + await expect(resolvePinnedHostname("api.telegram.org", lookup)).rejects.toThrow( + /private|internal/i, + ); + + const pinned = await resolvePinnedHostnameWithPolicy("api.telegram.org", { + lookupFn: lookup, + policy: { allowRfc2544BenchmarkRange: true }, + }); expect(pinned.addresses).toContain("198.18.0.153"); }); diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 1bb2d77db..582666919 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -3,6 +3,8 @@ 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", @@ -13,6 +15,7 @@ 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", @@ -29,6 +32,7 @@ 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", @@ -41,18 +45,13 @@ 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", @@ -120,13 +119,17 @@ describe("isBlockedHostnameOrIp", () => { expect(isBlockedHostnameOrIp("2001:db8::1")).toBe(false); }); - 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); + it("blocks IPv4 special-use ranges but allows adjacent public ranges", () => { + expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(true); 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("supports opt-in policy to allow RFC2544 benchmark range", () => { + const policy = { allowRfc2544BenchmarkRange: true }; + expect(isBlockedHostnameOrIp("198.18.0.1")).toBe(true); + expect(isBlockedHostnameOrIp("198.18.0.1", policy)).toBe(false); + expect(isBlockedHostnameOrIp("::ffff:198.18.0.1", policy)).toBe(false); + expect(isBlockedHostnameOrIp("198.51.100.1", policy)).toBe(true); }); it("blocks legacy IPv4 literal representations", () => { diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 3a4456e78..2e4c69210 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -5,6 +5,7 @@ import { extractEmbeddedIpv4FromIpv6, isBlockedSpecialUseIpv4Address, isCanonicalDottedDecimalIPv4, + type Ipv4SpecialUseBlockOptions, isIpv4Address, isLegacyIpv4Literal, isPrivateOrLoopbackIpAddress, @@ -31,6 +32,7 @@ export type LookupFn = typeof dnsLookup; export type SsrFPolicy = { allowPrivateNetwork?: boolean; dangerouslyAllowPrivateNetwork?: boolean; + allowRfc2544BenchmarkRange?: boolean; allowedHostnames?: string[]; hostnameAllowlist?: string[]; }; @@ -65,6 +67,12 @@ function resolveAllowPrivateNetwork(policy?: SsrFPolicy): boolean { return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true; } +function resolveIpv4SpecialUseBlockOptions(policy?: SsrFPolicy): Ipv4SpecialUseBlockOptions { + return { + allowRfc2544BenchmarkRange: policy?.allowRfc2544BenchmarkRange === true, + }; +} + function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean { if (pattern.startsWith("*.")) { const suffix = pattern.slice(2); @@ -97,7 +105,7 @@ function looksLikeUnsupportedIpv4Literal(address: string): boolean { } // Returns true for private/internal and special-use non-global addresses. -export function isPrivateIpAddress(address: string): boolean { +export function isPrivateIpAddress(address: string, policy?: SsrFPolicy): boolean { let normalized = address.trim().toLowerCase(); if (normalized.startsWith("[") && normalized.endsWith("]")) { normalized = normalized.slice(1, -1); @@ -105,18 +113,19 @@ export function isPrivateIpAddress(address: string): boolean { if (!normalized) { return false; } + const blockOptions = resolveIpv4SpecialUseBlockOptions(policy); const strictIp = parseCanonicalIpAddress(normalized); if (strictIp) { if (isIpv4Address(strictIp)) { - return isBlockedSpecialUseIpv4Address(strictIp); + return isBlockedSpecialUseIpv4Address(strictIp, blockOptions); } if (isPrivateOrLoopbackIpAddress(strictIp.toString())) { return true; } const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(strictIp); if (embeddedIpv4) { - return isBlockedSpecialUseIpv4Address(embeddedIpv4); + return isBlockedSpecialUseIpv4Address(embeddedIpv4, blockOptions); } return false; } @@ -154,27 +163,30 @@ function isBlockedHostnameNormalized(normalized: string): boolean { ); } -export function isBlockedHostnameOrIp(hostname: string): boolean { +export function isBlockedHostnameOrIp(hostname: string, policy?: SsrFPolicy): boolean { const normalized = normalizeHostname(hostname); if (!normalized) { return false; } - return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized); + return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized, policy); } const BLOCKED_HOST_OR_IP_MESSAGE = "Blocked hostname or private/internal/special-use IP address"; const BLOCKED_RESOLVED_IP_MESSAGE = "Blocked: resolves to private/internal/special-use IP address"; -function assertAllowedHostOrIpOrThrow(hostnameOrIp: string): void { - if (isBlockedHostnameOrIp(hostnameOrIp)) { +function assertAllowedHostOrIpOrThrow(hostnameOrIp: string, policy?: SsrFPolicy): void { + if (isBlockedHostnameOrIp(hostnameOrIp, policy)) { throw new SsrFBlockedError(BLOCKED_HOST_OR_IP_MESSAGE); } } -function assertAllowedResolvedAddressesOrThrow(results: readonly LookupAddress[]): void { +function assertAllowedResolvedAddressesOrThrow( + results: readonly LookupAddress[], + policy?: SsrFPolicy, +): void { for (const entry of results) { // Reuse the exact same host/IP classifier as the pre-DNS check to avoid drift. - if (isBlockedHostnameOrIp(entry.address)) { + if (isBlockedHostnameOrIp(entry.address, policy)) { throw new SsrFBlockedError(BLOCKED_RESOLVED_IP_MESSAGE); } } @@ -264,7 +276,7 @@ export async function resolvePinnedHostnameWithPolicy( if (!skipPrivateNetworkChecks) { // Phase 1: fail fast for literal hosts/IPs before any DNS lookup side-effects. - assertAllowedHostOrIpOrThrow(normalized); + assertAllowedHostOrIpOrThrow(normalized, params.policy); } const lookupFn = params.lookupFn ?? dnsLookup; @@ -275,7 +287,7 @@ export async function resolvePinnedHostnameWithPolicy( if (!skipPrivateNetworkChecks) { // Phase 2: re-check DNS answers so public hostnames cannot pivot to private targets. - assertAllowedResolvedAddressesOrThrow(results); + assertAllowedResolvedAddressesOrThrow(results, params.policy); } const addresses = Array.from(new Set(results.map((entry) => entry.address))); diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts index a6b84ddd0..2342bdeda 100644 --- a/src/shared/net/ip.ts +++ b/src/shared/net/ip.ts @@ -28,13 +28,10 @@ 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]; +export type Ipv4SpecialUseBlockOptions = { + allowRfc2544BenchmarkRange?: boolean; +}; const EMBEDDED_IPV4_SENTINEL_RULES: Array<{ matches: (parts: number[]) => boolean; @@ -253,14 +250,15 @@ export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean { return parsed.range() === "carrierGradeNat"; } -export function isBlockedSpecialUseIpv4Address(address: ipaddr.IPv4): boolean { - 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. +export function isBlockedSpecialUseIpv4Address( + address: ipaddr.IPv4, + options: Ipv4SpecialUseBlockOptions = {}, +): boolean { + const inRfc2544BenchmarkRange = address.match(RFC2544_BENCHMARK_PREFIX); + if (inRfc2544BenchmarkRange && options.allowRfc2544BenchmarkRange === true) { return false; } - return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(range); + return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()) || inRfc2544BenchmarkRange; } function decodeIpv4FromHextets(high: number, low: number): ipaddr.IPv4 { diff --git a/src/telegram/bot/delivery.resolve-media-retry.test.ts b/src/telegram/bot/delivery.resolve-media-retry.test.ts index 2c54396a8..2becbcd93 100644 --- a/src/telegram/bot/delivery.resolve-media-retry.test.ts +++ b/src/telegram/bot/delivery.resolve-media-retry.test.ts @@ -92,6 +92,12 @@ async function expectTransientGetFileRetrySuccess() { await flushRetryTimers(); const result = await promise; expect(getFile).toHaveBeenCalledTimes(2); + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ + url: `https://api.telegram.org/file/bot${BOT_TOKEN}/voice/file_0.oga`, + ssrfPolicy: { allowRfc2544BenchmarkRange: true }, + }), + ); return result; } diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 945cd2c25..a20bf0456 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -35,6 +35,9 @@ import type { StickerMetadata, TelegramContext } from "./types.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; const FILE_TOO_BIG_RE = /file is too big/i; +const TELEGRAM_MEDIA_SSRF_POLICY = { + allowRfc2544BenchmarkRange: true, +} as const; export async function deliverReplies(params: { replies: ReplyPayload[]; @@ -320,6 +323,7 @@ export async function resolveMedia( fetchImpl, filePathHint: filePath, maxBytes, + ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY, }); const originalName = fetched.fileName ?? filePath; return saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes, originalName);