fix: scope Telegram RFC2544 SSRF exception to policy opt-in (#24982) (thanks @stakeswky)

This commit is contained in:
Peter Steinberger
2026-02-24 03:27:40 +00:00
parent 9df80b73e2
commit 3af9d1f8e9
8 changed files with 72 additions and 43 deletions

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

@@ -28,13 +28,10 @@ const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set<Ipv6Range>([
"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 {

View File

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

View File

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