diff --git a/CHANGELOG.md b/CHANGELOG.md index 01952d7c..b509a0eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to ClawRouter. --- +## v0.12.299 — October 22, 2026 + +### img2img URL downloads check what a host resolves to + +`/v1/images/image2image` downloads an `image` or `mask` given as a URL. The +SSRF guard checked the hostname as written, so a public name pointing at a +private address passed it: `localtest.me` and `127.0.0.1.nip.io` resolve to +loopback, `169.254.169.254.nip.io` to the cloud metadata service. And because +fetch() resolved the name a second time to connect, a TTL-0 record could +answer the check with a public address and the connection with a private one +(DNS rebinding). + +Every hop, redirects included, now resolves the host and refuses if any +address is loopback, private, link-local, CGNAT or metadata, then connects +only to the addresses that passed — the name is not resolved again. Host, SNI +and certificate checks still use the hostname. With an upstream proxy +configured the request still goes through it; the proxy resolves names +itself, so there the resolve-time check is the guard. +`CLAWROUTER_ALLOW_PRIVATE_FETCH=1` still opts in to a local image server. + +Exposure was limited: the proxy only accepts requests from local clients, which +can already reach these addresses themselves. The gap mattered for a local +agent steered by untrusted content into passing such a URL. + +- Thanks to Hunteranair for the report. + +--- + ## v0.12.298 — October 22, 2026 ### `clawrouter update` — one command updates both copies diff --git a/apps/desktop/electron/core/runtime.ts b/apps/desktop/electron/core/runtime.ts index 0b63af05..a01909a8 100644 --- a/apps/desktop/electron/core/runtime.ts +++ b/apps/desktop/electron/core/runtime.ts @@ -6,7 +6,7 @@ import type { AdapterContext } from "./types.js"; import { ensureServiceToken, verifyClawRouter } from "./service-auth.js"; /** Keep in sync with the root package; runtime-version.test.ts enforces it. */ -export const CLAWROUTER_PACKAGE_VERSION = "0.12.298"; +export const CLAWROUTER_PACKAGE_VERSION = "0.12.299"; export function managedBin(context: AdapterContext, name: string): string { const suffix = process.platform === "win32" ? ".cmd" : ""; diff --git a/package-lock.json b/package-lock.json index 8fe7c859..bfd84f0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@blockrun/clawrouter", - "version": "0.12.298", + "version": "0.12.299", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockrun/clawrouter", - "version": "0.12.298", + "version": "0.12.299", "license": "MIT", "dependencies": { "@blockrun/llm": "^3.23.1", diff --git a/package.json b/package.json index 64d4b2aa..fa8692dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/clawrouter", - "version": "0.12.298", + "version": "0.12.299", "description": "Smart LLM router — save 84% on inference costs. 90 models (7 free). Pay by credit card (API key) or USDC (x402 wallet).", "type": "module", "main": "dist/index.js", diff --git a/src/local-guard.test.ts b/src/local-guard.test.ts index 594bfa47..57d684e5 100644 --- a/src/local-guard.test.ts +++ b/src/local-guard.test.ts @@ -5,7 +5,15 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { isBlockedSsrfHost, readLocalImageAsDataUri, ssrfSafeFetch } from "./local-guard.js"; +import type { LookupAddress } from "node:dns"; +import { + isBlockedSsrfHost, + pinnedFetch, + pinnedLookup, + readLocalImageAsDataUri, + resolvePublicAddresses, + ssrfSafeFetch, +} from "./local-guard.js"; import { buildPartnerTools } from "./partners/tools.js"; import { startProxy, type ProxyHandle } from "./proxy.js"; @@ -45,25 +53,241 @@ describe("isBlockedSsrfHost", () => { }); }); +describe("resolvePublicAddresses", () => { + const dnsOf = + (table: Record) => + async (host: string): Promise => + (table[host] ?? []).map((address) => ({ address, family: address.includes(":") ? 6 : 4 })); + + it("refuses public names that resolve to private addresses", async () => { + const resolve = dnsOf({ + "localtest.me": ["127.0.0.1"], + "127.0.0.1.nip.io": ["127.0.0.1"], + "169.254.169.254.nip.io": ["169.254.169.254"], + "mapped.example": ["::ffff:127.0.0.1"], + // One bad address is enough: the connection may land on any of them. + "mixed.example": ["93.184.215.14", "10.0.0.5"], + }); + for (const host of [ + "localtest.me", + "127.0.0.1.nip.io", + "169.254.169.254.nip.io", + "mapped.example", + "mixed.example", + ]) { + await expect(resolvePublicAddresses(host, resolve), host).rejects.toThrow( + /private\/loopback\/metadata/, + ); + } + }); + + it("returns every address of a public name", async () => { + const resolve = dnsOf({ + "example.com": ["93.184.215.14", "2606:2800:21f:cb07:6820:80da:af6b:8b2c"], + }); + expect(await resolvePublicAddresses("example.com", resolve)).toHaveLength(2); + }); + + it("does not resolve IP literals", async () => { + const resolve = async () => { + throw new Error("resolver called"); + }; + expect(await resolvePublicAddresses("8.8.8.8", resolve)).toEqual([ + { address: "8.8.8.8", family: 4 }, + ]); + expect(await resolvePublicAddresses("[2001:4860:4860::8888]", resolve)).toEqual([ + { address: "2001:4860:4860::8888", family: 6 }, + ]); + }); + + it("refuses a name that resolves to nothing", async () => { + await expect(resolvePublicAddresses("void.example", dnsOf({}))).rejects.toThrow( + /could not resolve/, + ); + }); +}); + +describe("pinnedLookup", () => { + const addrs: LookupAddress[] = [ + { address: "93.184.215.14", family: 4 }, + { address: "2606:2800:21f:cb07:6820:80da:af6b:8b2c", family: 6 }, + ]; + + it("answers with the vetted addresses whatever name it is asked about", () => { + const lookup = pinnedLookup(addrs); + lookup("anything.example", { all: true }, (err, all) => { + expect(err).toBeNull(); + expect(all).toEqual(addrs); + }); + lookup("anything.example", {}, (err, address, family) => { + expect(err).toBeNull(); + expect(address).toBe("93.184.215.14"); + expect(family).toBe(4); + }); + }); +}); + +describe("pinnedFetch", () => { + let server: Server; + let port: number; + const seen: Array<{ host?: string; url?: string; method?: string; body: string }> = []; + + beforeAll(async () => { + server = createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + seen.push({ host: req.headers.host, url: req.url, method: req.method, body }); + if (req.url === "/redirect") { + res.writeHead(302, { Location: "http://127.0.0.1/elsewhere" }); + res.end(); + } else if (req.url === "/empty") { + res.writeHead(204); + res.end(); + } else { + res.writeHead(200, { "Content-Type": "image/png", "X-Multi": ["a", "b"] }); + res.end(PNG); + } + }); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + port = (server.address() as AddressInfo).port; + }); + afterAll(() => new Promise((r) => server.close(() => r()))); + beforeEach(() => { + seen.length = 0; + }); + + const loopback: LookupAddress[] = [{ address: "127.0.0.1", family: 4 }]; + + it("connects to the pinned address, never re-resolving the name", async () => { + // .invalid never resolves, so reaching the server proves no DNS was used. + const res = await pinnedFetch(new URL(`http://rebind.invalid:${port}/x.png`), loopback); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/png"); + expect(res.headers.get("x-multi")).toBe("a, b"); + expect(Buffer.from(await res.arrayBuffer())).toEqual(PNG); + expect(seen).toEqual([ + { host: `rebind.invalid:${port}`, url: "/x.png", method: "GET", body: "" }, + ]); + }); + + it("returns redirects instead of following them", async () => { + const res = await pinnedFetch(new URL(`http://rebind.invalid:${port}/redirect`), loopback); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("http://127.0.0.1/elsewhere"); + expect(seen).toHaveLength(1); + }); + + it("handles null-body statuses and request bodies", async () => { + const res = await pinnedFetch(new URL(`http://rebind.invalid:${port}/empty`), loopback, { + method: "POST", + body: "hello", + headers: { "Content-Type": "text/plain" }, + }); + expect(res.status).toBe(204); + expect(res.body).toBeNull(); + expect(seen[0]).toMatchObject({ method: "POST", body: "hello" }); + }); + + it("honors an abort signal", async () => { + const ac = new AbortController(); + ac.abort(); + await expect( + pinnedFetch(new URL(`http://rebind.invalid:${port}/x.png`), loopback, { signal: ac.signal }), + ).rejects.toThrow(/abort/i); + }); +}); + describe("ssrfSafeFetch", () => { - it("refuses a public URL that redirects into loopback", async () => { + const publicDns = async (): Promise => [{ address: "93.184.215.14", family: 4 }]; + + async function withFetch( + impl: (url: string) => Response, + run: (calls: string[]) => Promise, + ): Promise { const orig = globalThis.fetch; const calls: string[] = []; globalThis.fetch = (async (url: string | URL | Request) => { calls.push(String(url)); - return new Response("", { - status: 302, - headers: { Location: "http://127.0.0.1:8402/health" }, - }); + return impl(String(url)); }) as typeof fetch; try { - await expect(ssrfSafeFetch("https://attacker.example/x.png")).rejects.toThrow( - /private\/loopback/, - ); - expect(calls).toEqual(["https://attacker.example/x.png"]); + return await run(calls); } finally { globalThis.fetch = orig; } + } + + it("refuses a public URL that redirects into loopback", async () => { + await withFetch( + () => + new Response("", { status: 302, headers: { Location: "http://127.0.0.1:8402/health" } }), + async (calls) => { + await expect( + ssrfSafeFetch("https://attacker.example/x.png", { + viaUpstreamProxy: true, + resolve: publicDns, + }), + ).rejects.toThrow(/private\/loopback/); + expect(calls).toEqual(["https://attacker.example/x.png"]); + }, + ); + }); + + it("refuses a public name that resolves to a private address, before connecting", async () => { + const rebinding = async (): Promise => [ + { address: "169.254.169.254", family: 4 }, + ]; + for (const viaUpstreamProxy of [false, true]) { + await withFetch( + () => new Response("secret"), + async (calls) => { + await expect( + ssrfSafeFetch("http://169.254.169.254.nip.io/latest/meta-data/", { + viaUpstreamProxy, + resolve: rebinding, + }), + ).rejects.toThrow( + /resolves to a private\/loopback\/metadata address \(169\.254\.169\.254\)/, + ); + expect(calls).toEqual([]); + }, + ); + } + }); + + it("re-resolves every redirect hop", async () => { + const resolve = async (host: string): Promise => + host === "localtest.me" + ? [{ address: "127.0.0.1", family: 4 }] + : [{ address: "93.184.215.14", family: 4 }]; + await withFetch( + () => new Response("", { status: 302, headers: { Location: "http://localtest.me:8402/" } }), + async (calls) => { + await expect( + ssrfSafeFetch("https://attacker.example/x.png", { viaUpstreamProxy: true, resolve }), + ).rejects.toThrow(/localtest\.me: it resolves to/); + expect(calls).toEqual(["https://attacker.example/x.png"]); + }, + ); + }); + + it("allowPrivate skips every check", async () => { + const resolve = async () => { + throw new Error("resolver called"); + }; + await withFetch( + () => new Response("ok"), + async (calls) => { + const res = await ssrfSafeFetch("http://127.0.0.1:9/x.png", { + allowPrivate: true, + resolve, + }); + expect(await res.text()).toBe("ok"); + expect(calls).toEqual(["http://127.0.0.1:9/x.png"]); + }, + ); }); }); diff --git a/src/local-guard.ts b/src/local-guard.ts index 417741a7..77e9b424 100644 --- a/src/local-guard.ts +++ b/src/local-guard.ts @@ -12,13 +12,19 @@ * clients (OpenClaw, curl, SDKs) send no Origin and are unaffected. * * 2. URLs supplied in a request body are fetched only from public hosts, - * with every redirect re-checked (isBlockedSsrfHost / ssrfSafeFetch). + * checked as written and as resolved, connected only to the addresses + * that passed, with every redirect re-checked (ssrfSafeFetch). * * 3. A "local image path" in a request is really an image, so the img2img * route cannot be pointed at the wallet key file (readLocalImageAsDataUri). */ -import type { IncomingMessage } from "node:http"; +import { request as httpRequest, type IncomingMessage } from "node:http"; +import { request as httpsRequest } from "node:https"; +import type { LookupAddress } from "node:dns"; +import { lookup as dnsLookup } from "node:dns/promises"; +import { isIP, type LookupFunction } from "node:net"; +import { Readable } from "node:stream"; import { closeSync, fstatSync, openSync, readSync, realpathSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -86,7 +92,8 @@ function v4FromHex(hi: number, lo: number): string { /** * True for loopback, private, link-local, CGNAT and cloud-metadata hosts, * including the IPv6 forms that embed an IPv4 address. Literal-host based: - * it does not resolve DNS. + * it does not resolve DNS — resolvePublicAddresses() applies it to what a + * name resolves to. */ export function isBlockedSsrfHost(hostname: string): boolean { const h = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, ""); @@ -130,26 +137,149 @@ export function isBlockedSsrfHost(hostname: string): boolean { return false; } +export type SsrfResolver = (hostname: string) => Promise; + +const systemResolver: SsrfResolver = (hostname) => + dnsLookup(hostname, { all: true, verbatim: true }); + +/** + * Every address `hostname` resolves to, refusing if ANY of them is blocked. + * + * isBlockedSsrfHost() only sees the name, and a public name can point + * anywhere: localtest.me and 127.0.0.1.nip.io resolve to loopback, + * 169.254.169.254.nip.io to the metadata service. All addresses are checked, + * not the first, because the connection may land on any of them. + */ +export async function resolvePublicAddresses( + hostname: string, + resolve: SsrfResolver = systemResolver, +): Promise { + const h = hostname.replace(/^\[/, "").replace(/\]$/, ""); + const family = isIP(h); + const addrs = family ? [{ address: h, family }] : await resolve(h); + if (addrs.length === 0) throw new Error(`could not resolve ${hostname}`); + const blocked = addrs.find((a) => isBlockedSsrfHost(a.address)); + if (blocked) { + throw new Error( + `refusing to fetch ${hostname}: it resolves to a private/loopback/metadata address (${blocked.address})`, + ); + } + return addrs; +} + +/** + * A socket lookup that answers with the addresses already vetted, whatever + * name it is asked about. Resolving again at connect time is the DNS-rebinding + * gap: a TTL-0 record can answer the check with a public address and the + * connect with 127.0.0.1. Node asks with `all: true` when it races address + * families, so both callback shapes are served. + */ +export function pinnedLookup(addrs: LookupAddress[]): LookupFunction { + return (_hostname, options, callback) => { + if (options.all) { + callback(null, addrs); + } else { + callback(null, addrs[0].address, addrs[0].family); + } + }; +} + +const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]); + +/** + * One request, no redirects, connected only to `addrs`. The URL keeps its + * hostname, so Host, SNI and certificate checks are unchanged. + * + * Unlike fetch() it sends no Accept-Encoding and does not decode bodies. + */ +export function pinnedFetch( + url: URL, + addrs: LookupAddress[], + init: RequestInit = {}, +): Promise { + const method = (init.method ?? "GET").toUpperCase(); + const body = init.body; + if (body != null && typeof body !== "string" && !(body instanceof Uint8Array)) { + return Promise.reject(new TypeError("pinnedFetch supports only string or byte bodies")); + } + const headers = Object.fromEntries(new Headers(init.headers)); + const send = url.protocol === "https:" ? httpsRequest : httpRequest; + + return new Promise((resolve, reject) => { + const req = send( + url, + { method, headers, lookup: pinnedLookup(addrs), signal: init.signal ?? undefined }, + (res) => { + const status = res.statusCode ?? 0; + if (status < 200 || status > 599) { + res.destroy(); + reject(new Error(`unexpected HTTP status ${status} from ${url.host}`)); + return; + } + const resHeaders = new Headers(); + for (let i = 0; i + 1 < res.rawHeaders.length; i += 2) { + resHeaders.append(res.rawHeaders[i], res.rawHeaders[i + 1]); + } + const noBody = method === "HEAD" || NULL_BODY_STATUSES.has(status); + if (noBody) res.resume(); + resolve( + new Response(noBody ? null : (Readable.toWeb(res) as ReadableStream), { + status, + statusText: res.statusMessage, + headers: resHeaders, + }), + ); + }, + ); + req.on("error", reject); + req.end(body ?? undefined); + }); +} + +export interface SsrfFetchInit extends RequestInit { + /** Skip every private-address check (CLAWROUTER_ALLOW_PRIVATE_FETCH). */ + allowPrivate?: boolean; + /** + * An upstream proxy is the global dispatcher. Requests must go through it, + * and it resolves names itself, so the connection cannot be pinned: only + * the resolve-time check applies. + */ + viaUpstreamProxy?: boolean; + /** Test seam; defaults to the system resolver. */ + resolve?: SsrfResolver; +} + /** - * fetch() that follows redirects manually and re-checks the host on every - * hop, so a public URL can't 30x into 169.254.169.254 or the proxy itself. + * fetch() for URLs taken from a request body. On every hop, redirects + * included, the host is checked as written and as resolved, and the + * connection is pinned to the addresses that passed, so neither a 30x nor a + * name pointing (or re-pointing) at 169.254.169.254 or the proxy itself gets + * through. */ export async function ssrfSafeFetch( url: string, - init: RequestInit & { allowPrivate?: boolean } = {}, + init: SsrfFetchInit = {}, maxHops = 5, ): Promise { - const { allowPrivate, ...fetchInit } = init; + const { allowPrivate, viaUpstreamProxy, resolve, ...fetchInit } = init; let current = url; for (let hop = 0; hop <= maxHops; hop++) { const u = new URL(current); if (u.protocol !== "http:" && u.protocol !== "https:") { throw new Error(`refusing to fetch non-http(s) URL (${u.protocol})`); } - if (!allowPrivate && isBlockedSsrfHost(u.hostname)) { - throw new Error(`refusing to fetch a private/loopback/metadata address: ${u.hostname}`); + let res: Response; + if (allowPrivate) { + res = await fetch(current, { ...fetchInit, redirect: "manual" }); + } else { + if (isBlockedSsrfHost(u.hostname)) { + throw new Error(`refusing to fetch a private/loopback/metadata address: ${u.hostname}`); + } + const addrs = await resolvePublicAddresses(u.hostname, resolve); + res = viaUpstreamProxy + ? await fetch(current, { ...fetchInit, redirect: "manual" }) + : await pinnedFetch(u, addrs, fetchInit); } - const res = await fetch(current, { ...fetchInit, redirect: "manual" }); if (res.status >= 300 && res.status < 400) { const loc = res.headers.get("location"); if (!loc) return res; diff --git a/src/proxy.ts b/src/proxy.ts index 4d503ae2..45f84188 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -3788,6 +3788,7 @@ export async function startProxy(options: ProxyOptions): Promise { const imgResp = await ssrfSafeFetch(val, { signal: clientAbort.signal, allowPrivate: process.env.CLAWROUTER_ALLOW_PRIVATE_FETCH === "1", + viaUpstreamProxy: Boolean(upstreamProxy), }); if (!imgResp.ok) throw new Error(`Failed to download ${field} from ${val}: HTTP ${imgResp.status}`);