From 31e819abadde6606c667d7a4932e3d765c37413a Mon Sep 17 00:00:00 2001 From: zouyicheng Date: Tue, 19 May 2026 19:43:30 +0800 Subject: [PATCH] fix(webfetch): manual redirect loop with SSRF + cross-host guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fix daemonFetchUrl set axios `maxRedirects:10` and trusted the adapter to follow any 3xx Location header blindly. The WebFetch tool's permission card gates only the **initial** hostname; any subsequent redirect could land on: - cloud metadata IP literals (`169.254.169.254` AWS / `100.100.100.200` Alibaba / etc.) and read instance credentials, - RFC1918 / loopback / link-local addresses behind a corp proxy, - a different public hostname the user never approved. The tool_result's `finalUrl` showed where the chain ended, but by then the daemon had already issued the request and (for binary downloads) persisted bytes to `.lightclaw/downloads/`. SSRF-like. New behavior: - **Manual redirect loop**, max 10 hops. axios `maxRedirects:0` + `validateStatus` widened to accept 3xx so we see Location ourselves. - **SSRF hard-block**: every hop's destination URL is classified via the new `classifyRedirectTarget`. IP literals in 127/8 (loopback), 10/8 / 172.16/12 / 192.168/16 (RFC1918), 169.254/16 (link-local + cloud metadata), 100.64/10 (CGNAT incl. Alibaba), 198.18/15 (RFC2544), 0/8, 192.0.0/24, plus IPv6 `::1` / fc00::/7 ULA / fe80::/10 link-local / IPv4-mapped IPv6 in both dotted and Node's canonical hex form, plus `localhost`/`*.localhost` and non-HTTP(S) schemes (file:// / gopher:// / ftp://) all throw `SsrfRedirectError`. Mode-independent — even in `acceptEdits` (auto) mode these are blocked. - **Strict same-host follow**: any hostname change (case-insensitive) throws `CrossHostRedirectError` with the would-be target captured. The model's recovery is to re-issue `WebFetch()` directly, which goes through normal per-hostname permission gating — auto-allowed in `acceptEdits` mode (no card; existing `policy.ts:119` rule), card in default mode. - **Redirect chain captured**: `DaemonFetchResult.redirectChain: string[]` records every visited URL. Empty on no-redirect; populated for same-host normalizations (HTTPS upgrade, trailing-slash). Error envelopes include the chain so the model can see where the redirect wanted to go. - **Max-hops exceeded**: throws `RedirectLimitError`. Permission card surface is unchanged. The fix lives entirely in the daemon-fetch layer; it never calls `requestPermission()` and never introduces a new card path. Auto mode stays card-free per the existing WebFetch auto-allow rule in `policy.ts:119`. Out of scope for V1 (gaps documented in code header): - DNS resolution of named hosts to catch internal hostnames that resolve to private IPs. The cross-host rule catches the named-internal case because the hostname differs from the user-approved initial host. - Same-registrable-domain follow (e.g. `docs.foo.com → static.foo.com`) and short-link whitelist (`t.co`/`bit.ly`). Strict same-host is the safest default; relax after dogfood. - DNS rebinding defense via per-hop IP pinning. The static-IP-literal SSRF case (the high-value cloud-metadata vector) is fully covered. Tests: - Existing 308 same-host trailing-slash case rewritten — axios no longer auto-follows, so the test now drives two stubbed responses and asserts the manual loop walked both URLs + captured chain. - New cases (16 in two suites): - same-host single redirect + multi-hop chain captured - cross-host redirect throws CrossHostRedirectError + target never fetched - SSRF: AWS metadata / RFC1918 / loopback / Alibaba / localhost / non-HTTP scheme all throw SsrfRedirectError - max-hops loop throws RedirectLimitError - 3xx without Location → AxiosError surfaced - existing 4xx-not-retried / timeout / 503-retry / socket-reset retry cases all preserved unchanged - `classifyRedirectTarget` exhaustive coverage: ordinary public IPs pass; cloud metadata / RFC1918 / loopback / CGNAT / benchmarking / localhost aliases / IPv6 ULA / IPv6 link-local / IPv4-mapped IPv6 (both dotted and hex form) / non-HTTP schemes / unparseable URLs - `pnpm typecheck` clean - `pnpm test` 1442/1442 PASS (1422 baseline + 20 new) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tools/web-fetch-cache.test.ts | 1 + src/tools/web-fetch-http.test.ts | 467 +++++++++++++++++++++++++----- src/tools/web-fetch-http.ts | 451 +++++++++++++++++++++++++---- src/tools/web-fetch.test.ts | 4 + src/tools/web-fetch.ts | 47 ++- 5 files changed, 831 insertions(+), 139 deletions(-) diff --git a/src/tools/web-fetch-cache.test.ts b/src/tools/web-fetch-cache.test.ts index 672fd3b5..ad5e1563 100644 --- a/src/tools/web-fetch-cache.test.ts +++ b/src/tools/web-fetch-cache.test.ts @@ -71,6 +71,7 @@ function buildCtxWithFetch(body: string, fetchCount: { n: number }): ToolCallCon finalUrl: 'https://example.com/', contentType: 'text/plain; charset=utf-8', bytes: Buffer.from(body, 'utf-8'), + redirectChain: [], } }) return { diff --git a/src/tools/web-fetch-http.test.ts b/src/tools/web-fetch-http.test.ts index 7a254c33..c8f68944 100644 --- a/src/tools/web-fetch-http.test.ts +++ b/src/tools/web-fetch-http.test.ts @@ -4,6 +4,10 @@ import { afterEach, describe, it } from 'node:test' import { AxiosError } from 'axios' import { + CrossHostRedirectError, + RedirectLimitError, + SsrfRedirectError, + _internalsForTests, _setHttpGetForTests, daemonFetchUrl, } from './web-fetch-http.js' @@ -11,31 +15,34 @@ import { _setWebRetryDelaysForTests } from './web-retry.js' function buildResponse(opts: { status?: number - data: ArrayBuffer | Buffer + data?: ArrayBuffer | Buffer contentType?: string - finalUrl?: string + location?: string }): unknown { + const dataBuf = + opts.data ?? Buffer.from('') const data = - opts.data instanceof Buffer - ? opts.data.buffer.slice( - opts.data.byteOffset, - opts.data.byteOffset + opts.data.byteLength, + dataBuf instanceof Buffer + ? dataBuf.buffer.slice( + dataBuf.byteOffset, + dataBuf.byteOffset + dataBuf.byteLength, ) - : opts.data + : dataBuf // Build a minimal axios-shaped object — only fields daemonFetchUrl reads. - // `request.res.responseUrl` is the Node http-adapter post-redirect URL. - // Return as `unknown` so the test stub's generic doesn't have to match - // axios's strict AxiosResponse shape (which requires - // InternalAxiosRequestConfig.headers to be AxiosHeaders, not undefined). + // The new implementation reads `headers.location` for 3xx hops and + // `headers.content-type` for 2xx terminations; finalUrl is now tracked + // by daemonFetchUrl itself (no longer pulled from request.res.responseUrl). + const headers: Record = { + 'content-type': opts.contentType ?? 'text/html; charset=utf-8', + } + if (opts.location !== undefined) headers.location = opts.location return { status: opts.status ?? 200, - statusText: 'OK', + statusText: opts.status === 200 || opts.status === undefined ? 'OK' : '', data, - headers: { 'content-type': opts.contentType ?? 'text/html; charset=utf-8' }, + headers, config: {}, - request: { - res: { responseUrl: opts.finalUrl ?? 'https://example.com/' }, - }, + request: {}, } } @@ -45,14 +52,13 @@ describe('web-fetch-http (unit, stubbed axios)', () => { _setWebRetryDelaysForTests(null) }) - it('200 OK → returns status / finalUrl / contentType / bytes correctly', async () => { + it('200 OK → returns status / finalUrl / contentType / bytes / empty redirectChain', async () => { const payload = Buffer.from('hi', 'utf-8') // eslint-disable-next-line @typescript-eslint/no-explicit-any _setHttpGetForTests((async () => buildResponse({ data: payload, contentType: 'text/html; charset=utf-8', - finalUrl: 'https://example.com/page', })) as any) const ctrl = new AbortController() const result = await daemonFetchUrl('https://example.com/page', ctrl.signal) @@ -60,40 +66,290 @@ describe('web-fetch-http (unit, stubbed axios)', () => { assert.equal(result.finalUrl, 'https://example.com/page') assert.equal(result.contentType, 'text/html; charset=utf-8') assert.equal(result.bytes.toString('utf-8'), 'hi') + assert.deepEqual(result.redirectChain, []) }) - it('308 redirect chain handled by axios maxRedirects:10 → finalUrl differs from input', async () => { - // We don't need to simulate the actual hop sequence; axios's internal - // adapter does that and exposes the terminal URL on - // request.res.responseUrl. The unit-test contract is: daemonFetchUrl - // surfaces whatever final URL axios reports, NOT the initial input. - // (This is the regression case for 5/11 Bug 11: pre-httpx urllib raised - // HTTPError on 308 and finalUrl never moved; post-migration axios - // follows 308 and finalUrl reflects the trailing-slash terminal.) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _setHttpGetForTests((async () => - buildResponse({ - data: Buffer.from('final'), - finalUrl: 'https://www.alphaxiv.org/feed/', // 308 terminal after /feed - })) as any) + it('308 same-host redirect (trailing slash) → manual loop follows, finalUrl differs, chain captured', async () => { + // Pre-fix axios maxRedirects:10 silently followed; post-fix daemonFetchUrl + // walks the chain itself. Regression case for 5/11 Bug 11. + const calls: string[] = [] + _setHttpGetForTests(async (url) => { + calls.push(url) + if (url === 'https://www.alphaxiv.org/feed') { + return buildResponse({ + status: 308, + location: 'https://www.alphaxiv.org/feed/', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + } + if (url === 'https://www.alphaxiv.org/feed/') { + return buildResponse({ + data: Buffer.from('final'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + } + throw new Error(`unexpected url ${url}`) + }) const ctrl = new AbortController() const result = await daemonFetchUrl('https://www.alphaxiv.org/feed', ctrl.signal) assert.equal(result.finalUrl, 'https://www.alphaxiv.org/feed/') - assert.notEqual(result.finalUrl, 'https://www.alphaxiv.org/feed') + assert.deepEqual(calls, [ + 'https://www.alphaxiv.org/feed', + 'https://www.alphaxiv.org/feed/', + ]) + assert.deepEqual(result.redirectChain, ['https://www.alphaxiv.org/feed/']) + }) + + it('multi-hop same-host chain → all hops followed, chain captures every URL', async () => { + _setHttpGetForTests(async (url) => { + if (url === 'https://docs.example.com/v1/api') { + return buildResponse({ + status: 301, + location: 'https://docs.example.com/v2/api', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + } + if (url === 'https://docs.example.com/v2/api') { + return buildResponse({ + status: 302, + location: '/v2/api/', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + } + if (url === 'https://docs.example.com/v2/api/') { + return buildResponse({ + data: Buffer.from('final'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + } + throw new Error(`unexpected url ${url}`) + }) + const ctrl = new AbortController() + const result = await daemonFetchUrl( + 'https://docs.example.com/v1/api', + ctrl.signal, + ) + assert.equal(result.finalUrl, 'https://docs.example.com/v2/api/') + assert.deepEqual(result.redirectChain, [ + 'https://docs.example.com/v2/api', + 'https://docs.example.com/v2/api/', + ]) + }) + + it('cross-host redirect → throws CrossHostRedirectError without fetching the new host', async () => { + const calls: string[] = [] + _setHttpGetForTests(async (url) => { + calls.push(url) + if (url === 'https://example.com/short') { + return buildResponse({ + status: 302, + location: 'https://different.com/long', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + } + throw new Error(`unexpected url ${url}`) + }) + const ctrl = new AbortController() + await assert.rejects( + () => daemonFetchUrl('https://example.com/short', ctrl.signal), + (err: unknown) => { + assert.ok(err instanceof CrossHostRedirectError, 'expected CrossHostRedirectError') + assert.equal(err.fromHost, 'example.com') + assert.equal(err.toUrl, 'https://different.com/long') + // Chain holds the ORIGIN + every accepted hop. The cross-host target + // is NOT in the accepted chain (we threw before accepting it); the + // error message body shows it via `.toUrl`. + assert.deepEqual(err.redirectChain, ['https://example.com/short']) + return true + }, + ) + // Critical: only the origin URL was actually fetched. The would-be + // cross-host target is NEVER requested — that's the whole defense. + assert.deepEqual(calls, ['https://example.com/short']) + }) + + it('SSRF: redirect to AWS metadata IP literal (169.254.169.254) is blocked', async () => { + const calls: string[] = [] + _setHttpGetForTests(async (url) => { + calls.push(url) + if (url === 'https://example.com/oauth') { + return buildResponse({ + status: 302, + location: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + } + throw new Error(`unexpected url ${url}`) + }) + const ctrl = new AbortController() + await assert.rejects( + () => daemonFetchUrl('https://example.com/oauth', ctrl.signal), + (err: unknown) => { + assert.ok(err instanceof SsrfRedirectError, 'expected SsrfRedirectError') + assert.match(err.reason, /169\.254\.0\.0\/16|link-local|metadata/) + assert.equal( + err.blockedUrl, + 'http://169.254.169.254/latest/meta-data/iam/security-credentials/', + ) + return true + }, + ) + assert.deepEqual(calls, ['https://example.com/oauth'], 'metadata IP never reached') + }) + + it('SSRF: redirect to RFC1918 IP (10.0.0.1) is blocked', async () => { + _setHttpGetForTests(async (url) => { + if (url === 'https://example.com/redir') { + return buildResponse({ + status: 302, + location: 'http://10.0.0.1/admin', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + } + throw new Error(`unexpected url ${url}`) + }) + const ctrl = new AbortController() + await assert.rejects( + () => daemonFetchUrl('https://example.com/redir', new AbortController().signal), + (err: unknown) => { + assert.ok(err instanceof SsrfRedirectError) + assert.match(err.reason, /10\.0\.0\.0\/8|RFC1918/) + return true + }, + ) + }) + + it('SSRF: redirect to loopback (127.0.0.1) is blocked', async () => { + _setHttpGetForTests(async (url) => { + if (url === 'https://example.com/redir') { + return buildResponse({ + status: 302, + location: 'http://127.0.0.1:8080/internal', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + } + throw new Error(`unexpected url ${url}`) + }) + await assert.rejects( + () => + daemonFetchUrl('https://example.com/redir', new AbortController().signal), + (err: unknown) => { + assert.ok(err instanceof SsrfRedirectError) + assert.match(err.reason, /127\.0\.0\.0\/8|loopback/) + return true + }, + ) + }) + + it('SSRF: redirect to literal "localhost" hostname is blocked', async () => { + _setHttpGetForTests(async (url) => { + if (url === 'https://example.com/redir') { + return buildResponse({ + status: 302, + location: 'http://localhost/admin', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + } + throw new Error(`unexpected url ${url}`) + }) + await assert.rejects( + () => + daemonFetchUrl('https://example.com/redir', new AbortController().signal), + (err: unknown) => { + assert.ok(err instanceof SsrfRedirectError) + assert.match(err.reason, /localhost/) + return true + }, + ) }) - it('4xx response → axios throws AxiosError; daemonFetchUrl re-throws unchanged', async () => { - // Axios's default validateStatus rejects 4xx/5xx — we keep that default - // so the WebFetch tool's `WebFetch failed (exit 1): fetch failed: ` - // envelope wraps the AxiosError.message naturally. No custom error - // shape; admin grep gets the standard `Request failed with status code 404` - // or `HTTP 404 Not Found` line. + it('SSRF: redirect to Alibaba Cloud metadata (100.100.100.200) is blocked', async () => { + _setHttpGetForTests(async (url) => { + if (url === 'https://example.com/redir') { + return buildResponse({ + status: 302, + location: 'http://100.100.100.200/latest/meta-data/', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + } + throw new Error(`unexpected url ${url}`) + }) + await assert.rejects( + () => + daemonFetchUrl('https://example.com/redir', new AbortController().signal), + (err: unknown) => { + assert.ok(err instanceof SsrfRedirectError) + assert.match(err.reason, /Alibaba/) + return true + }, + ) + }) + + it('SSRF: redirect to non-HTTP scheme (file://) is blocked', async () => { + _setHttpGetForTests(async (url) => { + if (url === 'https://example.com/redir') { + return buildResponse({ + status: 302, + location: 'file:///etc/passwd', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + } + throw new Error(`unexpected url ${url}`) + }) + await assert.rejects( + () => + daemonFetchUrl('https://example.com/redir', new AbortController().signal), + (err: unknown) => { + assert.ok(err instanceof SsrfRedirectError) + assert.match(err.reason, /non-HTTP/) + return true + }, + ) + }) + + it('max-hops exceeded → throws RedirectLimitError', async () => { + // Self-loop: each hop redirects to a different same-host path so we + // never hit "cross-host" but we do march past MAX_REDIRECTS. + _setHttpGetForTests(async (url) => { + const next = url.match(/\/p(\d+)$/) + const n = next ? Number.parseInt(next[1]!, 10) : 0 + return buildResponse({ + status: 302, + location: `https://example.com/p${n + 1}`, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + }) + await assert.rejects( + () => daemonFetchUrl('https://example.com/p0', new AbortController().signal), + (err: unknown) => { + assert.ok(err instanceof RedirectLimitError, 'expected RedirectLimitError') + assert.equal( + err.redirectChain.length, + _internalsForTests.MAX_REDIRECTS + 2, // origin + MAX_REDIRECTS + 1 hops attempted + ) + return true + }, + ) + }) + + it('3xx without Location header → AxiosError surfaced', async () => { + _setHttpGetForTests(async () => + buildResponse({ + status: 302, + // no location field + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ) + await assert.rejects( + () => + daemonFetchUrl('https://example.com/redir', new AbortController().signal), + /missing Location header/, + ) + }) + + it('4xx response → AxiosError re-thrown unchanged (no redirect handling change)', async () => { _setHttpGetForTests(async () => { const err = new AxiosError('Request failed with status code 404') - // Cast response through `unknown` — AxiosError.response is typed with - // InternalAxiosRequestConfig which requires the full Axios headers - // class; for unit-test purposes we only need .status visible to the - // test assertion below. // eslint-disable-next-line @typescript-eslint/no-explicit-any err.response = { status: 404, @@ -104,41 +360,29 @@ describe('web-fetch-http (unit, stubbed axios)', () => { } as any throw err }) - const ctrl = new AbortController() await assert.rejects( - () => daemonFetchUrl('https://example.com/missing', ctrl.signal), + () => + daemonFetchUrl('https://example.com/missing', new AbortController().signal), (err: Error) => { - assert.ok( - err.message.includes('404'), - `expected 404 in error message, got: ${err.message}`, - ) + assert.ok(err.message.includes('404')) return true }, ) }) - it('timeout (ECONNABORTED) → axios surfaces "timeout of Xms exceeded"; rethrown unchanged', async () => { - // The timeoutMs option arrives via axios config.timeout; when exceeded, - // axios throws AxiosError with code ECONNABORTED and message "timeout - // of ms exceeded". We don't customize this — admin sees the axios - // standard string. This is also what Bug 7 (WebSearch 实时联网查询 - // 环境超时) wanted: a clear timeout signal in stderr. + it('timeout → axios surfaces "timeout of Xms exceeded"; rethrown unchanged', async () => { _setHttpGetForTests(async () => { const err = new AxiosError('timeout of 60000ms exceeded') err.code = 'ECONNABORTED' throw err }) - const ctrl = new AbortController() await assert.rejects( - () => daemonFetchUrl('https://example.com/slow', ctrl.signal), + () => daemonFetchUrl('https://example.com/slow', new AbortController().signal), /timeout of 60000ms exceeded/, ) }) - it('transient socket error then 200: withWebRetry re-sends and recovers', async () => { - // The 2026-05-14 dogfood error: a socket reset on the proxy hop. Same - // failure class that hit WebSearch in the same windows — both tools - // share this HTTP layer's egress, so both get the retry. + it('transient socket reset then 200 → withWebRetry recovers (pre-redirect path)', async () => { _setWebRetryDelaysForTests({ baseDelayMs: 1, maxDelayMs: 2 }) let calls = 0 _setHttpGetForTests((async () => { @@ -151,19 +395,18 @@ describe('web-fetch-http (unit, stubbed axios)', () => { ;(err as any).code = 'ECONNRESET' throw err } - return buildResponse({ - data: Buffer.from('ok'), - finalUrl: 'https://example.com/page', - }) + return buildResponse({ data: Buffer.from('ok') }) // eslint-disable-next-line @typescript-eslint/no-explicit-any }) as any) - const ctrl = new AbortController() - const result = await daemonFetchUrl('https://example.com/page', ctrl.signal) + const result = await daemonFetchUrl( + 'https://example.com/page', + new AbortController().signal, + ) assert.equal(calls, 2) assert.equal(result.bytes.toString('utf-8'), 'ok') }) - it('503 Service Unavailable: retried via AxiosError.response.status, then succeeds', async () => { + it('503 then 200 → AxiosError.response.status retried (per-hop)', async () => { _setWebRetryDelaysForTests({ baseDelayMs: 1, maxDelayMs: 2 }) let calls = 0 _setHttpGetForTests((async () => { @@ -177,15 +420,15 @@ describe('web-fetch-http (unit, stubbed axios)', () => { return buildResponse({ data: Buffer.from('up') }) // eslint-disable-next-line @typescript-eslint/no-explicit-any }) as any) - const ctrl = new AbortController() - const result = await daemonFetchUrl('https://example.com/', ctrl.signal) + const result = await daemonFetchUrl( + 'https://example.com/', + new AbortController().signal, + ) assert.equal(calls, 3) assert.equal(result.bytes.toString('utf-8'), 'up') }) it('4xx is NOT retried: fn called exactly once', async () => { - // Regression guard: a 404 is deterministic — a re-send returns the same - // answer. The retry wiring must not widen the retryable set to 4xx. _setWebRetryDelaysForTests({ baseDelayMs: 1, maxDelayMs: 2 }) let calls = 0 _setHttpGetForTests(async () => { @@ -195,11 +438,87 @@ describe('web-fetch-http (unit, stubbed axios)', () => { err.response = { status: 404, statusText: 'Not Found' } as any throw err }) - const ctrl = new AbortController() await assert.rejects( - () => daemonFetchUrl('https://example.com/missing', ctrl.signal), + () => + daemonFetchUrl('https://example.com/missing', new AbortController().signal), /404/, ) assert.equal(calls, 1) }) }) + +describe('classifyRedirectTarget (SSRF guard)', () => { + const { classifyRedirectTarget } = _internalsForTests + + it('allows ordinary public-IP destinations', () => { + assert.equal(classifyRedirectTarget('https://example.com/'), null) + assert.equal(classifyRedirectTarget('https://93.184.216.34/'), null) // example.com's IP + assert.equal(classifyRedirectTarget('https://1.1.1.1/'), null) // Cloudflare DNS + assert.equal(classifyRedirectTarget('https://8.8.8.8/'), null) // Google DNS + }) + + it('blocks cloud metadata IPv4 literals', () => { + assert.match(classifyRedirectTarget('http://169.254.169.254/')!, /link-local|metadata/) + assert.match(classifyRedirectTarget('http://169.254.169.254/latest/meta-data/')!, /link-local|metadata/) + assert.match(classifyRedirectTarget('http://100.100.100.200/')!, /Alibaba/) + }) + + it('blocks all RFC1918 private ranges', () => { + assert.match(classifyRedirectTarget('http://10.0.0.1/')!, /10\.0\.0\.0\/8/) + assert.match(classifyRedirectTarget('http://10.255.255.255/')!, /10\.0\.0\.0\/8/) + assert.match(classifyRedirectTarget('http://172.16.0.1/')!, /172\.16\.0\.0\/12/) + assert.match(classifyRedirectTarget('http://172.31.255.255/')!, /172\.16\.0\.0\/12/) + assert.match(classifyRedirectTarget('http://192.168.1.1/')!, /192\.168\.0\.0\/16/) + assert.equal(classifyRedirectTarget('http://172.15.0.1/'), null, '172.15 is public') + assert.equal(classifyRedirectTarget('http://172.32.0.1/'), null, '172.32 is public') + }) + + it('blocks loopback / wildcard / CGNAT / benchmarking ranges', () => { + assert.match(classifyRedirectTarget('http://127.0.0.1/')!, /loopback/) + assert.match(classifyRedirectTarget('http://127.255.255.255/')!, /loopback/) + assert.match(classifyRedirectTarget('http://0.0.0.0/')!, /0\.0\.0\.0\/8/) + assert.match(classifyRedirectTarget('http://100.64.0.1/')!, /CGNAT/) + assert.match(classifyRedirectTarget('http://100.127.255.255/')!, /CGNAT/) + assert.match(classifyRedirectTarget('http://198.18.0.1/')!, /benchmarking/) + }) + + it('blocks localhost / *.localhost hostname aliases', () => { + assert.match(classifyRedirectTarget('http://localhost/')!, /localhost/) + assert.match(classifyRedirectTarget('http://api.localhost/')!, /localhost/) + assert.match(classifyRedirectTarget('http://LocalHost/')!, /localhost/, 'case-insensitive') + }) + + it('blocks IPv6 loopback / ULA / link-local', () => { + assert.match(classifyRedirectTarget('http://[::1]/')!, /loopback/) + assert.match(classifyRedirectTarget('http://[fc00::1]/')!, /ULA/) + assert.match(classifyRedirectTarget('http://[fd00::1]/')!, /ULA/) + assert.match(classifyRedirectTarget('http://[fe80::1]/')!, /link-local/) + }) + + it('blocks IPv4-mapped IPv6 → private IPv4', () => { + assert.match( + classifyRedirectTarget('http://[::ffff:169.254.169.254]/')!, + /IPv4-mapped IPv6.*link-local|IPv4-mapped IPv6.*metadata/, + ) + assert.match( + classifyRedirectTarget('http://[::ffff:10.0.0.1]/')!, + /IPv4-mapped IPv6.*RFC1918/, + ) + }) + + it('blocks non-HTTP(S) schemes', () => { + assert.match(classifyRedirectTarget('file:///etc/passwd')!, /non-HTTP/) + assert.match(classifyRedirectTarget('gopher://example.com/')!, /non-HTTP/) + assert.match(classifyRedirectTarget('ftp://example.com/')!, /non-HTTP/) + }) + + it('rejects unparseable URLs', () => { + assert.match(classifyRedirectTarget('not-a-url')!, /unparseable/) + assert.match(classifyRedirectTarget('http://')!, /unparseable/) + }) + + it('passes IPv6 public addresses', () => { + assert.equal(classifyRedirectTarget('http://[2001:db8::1]/'), null) + assert.equal(classifyRedirectTarget('http://[2606:4700::1]/'), null) // Cloudflare + }) +}) diff --git a/src/tools/web-fetch-http.ts b/src/tools/web-fetch-http.ts index 58dae139..104f8bd1 100644 --- a/src/tools/web-fetch-http.ts +++ b/src/tools/web-fetch-http.ts @@ -2,12 +2,38 @@ * Daemon-side HTTP layer for WebFetch / WebSearch. * * Mirrors Claude Code's `src/tools/WebFetchTool/utils.ts:262-329` shape but - * trades the per-hop same-origin redirect policy (an enterprise SSRF guard - * Claude Code carries) for axios's built-in cross-origin redirect follow. - * LightClaw's permission system already gates WebFetch by hostname, and the - * Phase 33 runtime model treats outbound HTTP as out-of-scope-for-sandbox — - * `t.co` / `bit.ly` / CDN cross-host redirects are dogfood-common and - * blocking them is a worse default than allowing them. + * with **manual redirect handling + SSRF guard** instead of axios's built-in + * cross-origin follow. The previous implementation set `maxRedirects:10` and + * trusted axios to follow blindly — a daemon-side WebFetch that the user + * approved on hostname `example.com` could 302-chain to: + * - cloud metadata IP literals (`169.254.169.254` AWS, `100.100.100.200` + * Alibaba, etc.) and read instance credentials; + * - internal RFC1918 addresses behind a corp proxy; + * - a different public hostname the user never saw / approved. + * SSRF-like. Permission card and runtime ACLs both gate on the **initial** + * hostname, not the post-redirect terminal. + * + * New behavior: + * 1. Manual loop, max 10 hops. axios `maxRedirects:0` + custom + * validateStatus that accepts 3xx so we see the `Location` header. + * 2. SSRF hard-block (mode-independent, no opt-out): every hop's + * destination URL is checked. If hostname is an IP literal in + * private / loopback / link-local ranges → throw `SsrfRedirectError`. + * Covers AWS / GCP / Alibaba metadata addresses + RFC1918 + loopback + + * IPv6 ULA / link-local. + * 3. Same-host follow: identical hostname (case-insensitive) → follow. + * Cross-host redirect (any hostname change, including same registrable + * domain like `docs.foo.com → static.foo.com`) → throw + * `CrossHostRedirectError`. The redirect chain is captured so the + * caller's tool_result can show the model where the chain wanted to + * go; the model can then re-issue `WebFetch()` directly, + * which goes through the normal per-hostname permission flow. + * 4. The guard is **purely in the daemon-fetch layer**: it never calls + * `requestPermission()` and never emits a permission card. In auto + * (`acceptEdits`) mode the existing policy (`policy.ts:119`) keeps + * WebFetch auto-allowed regardless of hostname — the model's + * cross-host re-issue under auto mode is still card-free. The card + * surface area is unchanged from baseline. * * Proxy: reuse `runtime.network.proxy` (`NetworkBridgeSettings.proxy`). * Semantically WebFetch IS outbound HTTP, just from the daemon process @@ -16,9 +42,32 @@ * `src/channels/feishu/transport-ws.ts:407` — `HttpsProxyAgent` on both * `httpAgent` and `httpsAgent`, plus `proxy: false` to disable axios's own * URL-string proxy honoring (avoids "proxy of proxy" double-wrap). + * + * Out of scope for V1 (acceptable gaps, documented for future hardening): + * - DNS resolution of named hosts to catch internal hostnames that + * resolve to private IPs. Proxy-fronted environments where the daemon + * can't resolve the name but the proxy can complicate the check; the + * "any hostname change → tool error" rule already catches the named + * internal case (a redirect to `internal.corp` is hostname-different + * from `example.com` → tool error). + * - Same-registrable-domain follow (e.g. `docs.foo.com → static.foo.com`) + * and short-link whitelist (`t.co`, `bit.ly`). Strict same-host is the + * safest default; relaxation can land after dogfood shows it's a real + * annoyance. + * - DNS rebinding defense (pin IP across redirect + body fetch). The + * SSRF guard catches the static-IP-literal case which is the high- + * value AWS/Alibaba metadata vector; full rebind defense requires + * either DNS pinning at the agent layer or per-hop IP capture which + * proxies don't expose. Defer. */ -import axios, { type AxiosResponse, type AxiosRequestConfig } from 'axios' +import { isIP } from 'node:net' + +import axios, { + AxiosError, + type AxiosResponse, + type AxiosRequestConfig, +} from 'axios' import { HttpsProxyAgent } from 'https-proxy-agent' import { getConfig } from '../config.js' @@ -68,9 +117,9 @@ const MAX_HTTP_CONTENT_LENGTH = 10 * 1024 * 1024 const DEFAULT_FETCH_TIMEOUT_MS = 60_000 /** 10 hops cap on redirect chain, Claude Code-aligned (`utils.ts:125`). - * Beyond this most legitimate redirect chains have looped; axios throws - * `ERR_FR_TOO_MANY_REDIRECTS`. We surface that as a user-readable error - * rather than a generic axios noise. */ + * Beyond this most legitimate redirect chains have looped; we throw + * `RedirectLimitError` and the WebFetch tool surfaces it as a clean tool + * error so the model can either give up or call a different URL. */ const MAX_REDIRECTS = 10 /** Browser UA so Cloudflare / Akamai / Distill don't 403 us as @@ -90,13 +139,62 @@ const BROWSER_HEADERS = { 'Chrome/131.0.0.0 Safari/537.36', } +/** Error thrown when the redirect destination is an IP literal in a + * private / loopback / link-local range. SSRF defense; mode-independent + * hard block. */ +export class SsrfRedirectError extends Error { + constructor( + public readonly redirectChain: string[], + public readonly blockedUrl: string, + public readonly reason: string, + ) { + super( + `WebFetch redirect blocked: ${reason} (target ${blockedUrl}, chain: ` + + `${redirectChain.join(' → ')})`, + ) + this.name = 'SsrfRedirectError' + } +} + +/** Error thrown when the redirect destination has a different hostname + * from the previous hop. The model can re-issue `WebFetch(finalTarget)` + * directly — that re-issue goes through the normal per-hostname + * permission flow (auto-allowed in `acceptEdits` mode; card in default + * mode). The daemon-fetch layer never pops a card itself. */ +export class CrossHostRedirectError extends Error { + constructor( + public readonly redirectChain: string[], + public readonly fromHost: string, + public readonly toUrl: string, + ) { + super( + `WebFetch redirect blocked: cross-host redirect from ${fromHost} to ` + + `${toUrl}. Re-issue WebFetch with the new URL if intended ` + + `(chain: ${redirectChain.join(' → ')})`, + ) + this.name = 'CrossHostRedirectError' + } +} + +/** Error thrown when the redirect chain exceeds MAX_REDIRECTS hops. */ +export class RedirectLimitError extends Error { + constructor(public readonly redirectChain: string[]) { + super( + `WebFetch redirect blocked: more than ${MAX_REDIRECTS} hops ` + + `(chain: ${redirectChain.join(' → ')})`, + ) + this.name = 'RedirectLimitError' + } +} + export type DaemonFetchResult = { /** Final HTTP status after redirect chain. Always 2xx on success path — * 4xx/5xx throw via {@link daemonFetchUrl}. */ status: number /** Final URL after redirect chain. Differs from the input `url` when - * HTTPS upgrade or hostname redirects happened. Used by - * `web-fetch-filename.ts` to derive download filenames. */ + * same-host redirects (HTTPS upgrade, trailing-slash normalization, + * same-host path moves) happened. Used by `web-fetch-filename.ts` to + * derive download filenames. */ finalUrl: string /** Raw `Content-Type` header. Lowercase + parameter parsing happens at * caller-side (`web-fetch-extract.ts` / `web-fetch-filename.ts`). */ @@ -105,12 +203,156 @@ export type DaemonFetchResult = { * decompressed by axios). Capped at MAX_HTTP_CONTENT_LENGTH; oversized * responses throw before reaching here. */ bytes: Buffer + /** Same-host redirect chain — the URLs visited from input to terminal, + * inclusive. Empty when no redirect happened (single 200 reply). + * Length === 1 when input is the same as final and one 3xx hop pointed + * back to a same-host path. Surfaced in the tool_result header so the + * agent can see HTTPS upgrades / trailing-slash normalization. */ + redirectChain: string[] } /** - * Single-shot HTTP GET with 10-redirect follow, browser UA, and proxy - * routing. Returns the full response body as Buffer — caller decides - * binary vs text path via `isBinaryContentType(contentType)`. + * Hard SSRF guard: reject any URL whose hostname is an IP literal in a + * private / loopback / link-local range, or whose hostname could plausibly + * be a localhost alias (`localhost`, `*.localhost`). Mode-independent — + * even in `acceptEdits` mode an attacker-controlled redirect MUST NOT + * fetch from these targets. + * + * Named hosts that resolve to private IPs but aren't IP literals are NOT + * blocked here (would require DNS resolution; see file header). The + * cross-host redirect rule catches the typical named-internal case + * because the hostname will differ from the user-approved initial host. + * + * Returns null when the URL is safe; returns the rejection reason string + * otherwise. Caller wraps in {@link SsrfRedirectError}. + */ +export function classifyRedirectTarget(url: string): string | null { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return 'unparseable redirect URL' + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return `non-HTTP(S) scheme ${parsed.protocol}` + } + // Node URL keeps the brackets on IPv6 hostnames (`[fc00::1]`); strip + // them before isIP / classify. Bare ASCII / IPv4 hostnames are + // unchanged. + const rawHost = parsed.hostname.toLowerCase() + const host = rawHost.startsWith('[') && rawHost.endsWith(']') + ? rawHost.slice(1, -1) + : rawHost + if (host === 'localhost' || host.endsWith('.localhost')) { + return 'localhost / *.localhost alias' + } + const ipKind = isIP(host) + if (ipKind === 4) { + return classifyIPv4(host) + } + if (ipKind === 6) { + return classifyIPv6(host) + } + return null +} + +/** Classify an IPv4 literal. Returns rejection reason or null. */ +function classifyIPv4(addr: string): string | null { + const parts = addr.split('.') + if (parts.length !== 4) return 'malformed IPv4' + const octets = parts.map(p => Number.parseInt(p, 10)) + if (octets.some(n => !Number.isFinite(n) || n < 0 || n > 255)) { + return 'malformed IPv4' + } + const [a, b] = octets as [number, number, number, number] + // 0.0.0.0/8 — "this network" / wildcard. Rejecting this also catches the + // "0.0.0.0" route-to-loopback trick some kernels still honor. + if (a === 0) return '0.0.0.0/8 (this network)' + // 10.0.0.0/8 — RFC1918 private + if (a === 10) return '10.0.0.0/8 (RFC1918 private)' + // 100.100.100.200 — Alibaba Cloud metadata. Lives inside the + // 100.64.0.0/10 CGNAT range but the more specific Alibaba label is + // useful for admin grep, so check it before the broader CGNAT rule. + if (addr === '100.100.100.200') { + return '100.100.100.200 (Alibaba Cloud metadata)' + } + // 100.64.0.0/10 — CGNAT shared address space (RFC6598). Used by mobile + // carrier NATs and some cloud internal service-mesh networks. + if (a === 100 && b >= 64 && b <= 127) { + return '100.64.0.0/10 (CGNAT shared address space)' + } + // 127.0.0.0/8 — loopback + if (a === 127) return '127.0.0.0/8 (loopback)' + // 169.254.0.0/16 — link-local + cloud metadata (AWS / Azure / GCP all + // use 169.254.169.254 here). + if (a === 169 && b === 254) { + return '169.254.0.0/16 (link-local / cloud metadata)' + } + // 172.16.0.0/12 — RFC1918 private (172.16.0.0 through 172.31.255.255) + if (a === 172 && b >= 16 && b <= 31) return '172.16.0.0/12 (RFC1918 private)' + // 192.168.0.0/16 — RFC1918 private + if (a === 192 && b === 168) return '192.168.0.0/16 (RFC1918 private)' + // 192.0.0.0/24 — IANA IPv4 Special Purpose Address Registry (includes + // 192.0.0.0 dummy, 192.0.0.8 dummy, etc.) + if (a === 192 && b === 0 && (octets[2] === 0)) { + return '192.0.0.0/24 (IANA reserved)' + } + // 198.18.0.0/15 — benchmarking (RFC2544) + if (a === 198 && (b === 18 || b === 19)) { + return '198.18.0.0/15 (RFC2544 benchmarking)' + } + return null +} + +/** Classify an IPv6 literal. Returns rejection reason or null. */ +function classifyIPv6(addr: string): string | null { + const lower = addr.toLowerCase() + // ::1 — loopback (also "0:0:0:0:0:0:0:1" expanded form) + if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') return '::1 (loopback)' + // ::ffff:a.b.c.d — IPv4-mapped IPv6 in dotted-quad form (rare; some + // libraries preserve it). Re-classify via the embedded v4. + const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(lower) + if (mappedDotted) { + const v4 = mappedDotted[1]! + const reason = classifyIPv4(v4) + return reason ? `IPv4-mapped IPv6 → ${reason}` : null + } + // ::ffff:HHHH:HHHH — Node's URL canonical form for IPv4-mapped IPv6 + // (e.g. `[::ffff:169.254.169.254]` normalizes to `::ffff:a9fe:a9fe`). + // Decode the last 32 bits into dotted-quad and reuse the v4 classifier. + const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(lower) + if (mappedHex) { + const hi = Number.parseInt(mappedHex[1]!, 16) + const lo = Number.parseInt(mappedHex[2]!, 16) + const v4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}` + const reason = classifyIPv4(v4) + return reason ? `IPv4-mapped IPv6 → ${reason}` : null + } + // fc00::/7 — Unique Local Addresses (ULA), the IPv6 analog of RFC1918. + // First byte is fc or fd. + if (lower.startsWith('fc') || lower.startsWith('fd')) { + // Confirm leading byte high bit; 'fc' = 0xfc, 'fd' = 0xfd, both in + // fc00::/7. Reject anything starting fc.. or fd.. before a colon. + const firstSegment = lower.split(':')[0] ?? '' + if (firstSegment.length <= 4 && /^f[cd][0-9a-f]{0,2}$/.test(firstSegment)) { + return 'fc00::/7 (IPv6 ULA private)' + } + } + // fe80::/10 — link-local + if (lower.startsWith('fe8') || lower.startsWith('fe9') || + lower.startsWith('fea') || lower.startsWith('feb')) { + const firstSegment = lower.split(':')[0] ?? '' + if (firstSegment.length <= 4 && /^fe[89ab][0-9a-f]{0,2}$/.test(firstSegment)) { + return 'fe80::/10 (IPv6 link-local)' + } + } + return null +} + +/** + * Single-shot HTTP GET with manual redirect handling, SSRF guard, browser + * UA, and proxy routing. Returns the full response body as Buffer — caller + * decides binary vs text path via `isBinaryContentType(contentType)`. * * On 4xx/5xx status throws `HTTP ` (the message that * the WebFetch tool wraps into its `WebFetch failed (exit 1): fetch @@ -121,6 +363,11 @@ export type DaemonFetchResult = { * `timeout of Xms exceeded`, etc.) — the type name is useful for admin * grep and the same WebFetch envelope wraps it. * + * On SSRF / cross-host / max-hop redirect violations throws the + * corresponding error class above; the WebFetch tool catches it and + * surfaces the chain to the model so it can decide whether to re-issue + * on the new URL. + * * @param url Full HTTP(S) URL. Caller is expected to have validated via * Zod / `URL` constructor; we don't re-validate here to keep the layer * thin and testable in isolation. @@ -130,7 +377,9 @@ export type DaemonFetchResult = { * message; the WebFetch envelope handles it the same as any throw. * @param timeoutMs Optional override; defaults to * {@link DEFAULT_FETCH_TIMEOUT_MS} (60 s). The WebFetch tool schema - * forwards its own `timeoutMs` here. + * forwards its own `timeoutMs` here. Applied **per hop** — a 10-hop + * chain that times out hop 5 throws after `5 * timeoutMs` worst case, + * matching axios's prior per-hop semantics under maxRedirects:10. */ export async function daemonFetchUrl( url: string, @@ -145,53 +394,127 @@ export async function daemonFetchUrl( // env never leaks into outbound HTTP, only the explicit config knob. const agent = proxy ? new HttpsProxyAgent(proxy) : undefined - // Retry transient network failures (socket reset / TLS-handshake abort — - // the 2026-05-14 proxy-blip dogfood — plus upstream 502/503/504, which - // axios's default validateStatus surfaces as AxiosError with - // `.response.status`). 4xx, axios timeout (ECONNABORTED), and abort are - // NOT retried; see web-retry.ts. The redirect / status / size handling - // below is unchanged — withWebRetry only re-sends the GET. - const response = await withWebRetry( - () => - httpGetFn(url, { - signal, - timeout: timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS, - maxRedirects: MAX_REDIRECTS, - maxContentLength: MAX_HTTP_CONTENT_LENGTH, - responseType: 'arraybuffer', - headers: BROWSER_HEADERS, - httpAgent: agent, - httpsAgent: agent, - proxy: false, - decompress: true, - // We want to surface every non-2xx as a thrown error with a clean - // `HTTP ` message — `validateStatus: () => true` would force - // us to test the response ourselves, but axios's default - // (`status >= 200 && status < 300`) already does the right thing - // with a more informative AxiosError. Stick with default. - }), - { label: 'WebFetch', signal }, - ) - - const contentTypeHeader = response.headers['content-type'] - const contentType = - typeof contentTypeHeader === 'string' - ? contentTypeHeader - : Array.isArray(contentTypeHeader) - ? contentTypeHeader[0] ?? '' - : '' - - // axios sets `response.request.res.responseUrl` to the final URL after - // redirects (Node http adapter). Browser builds don't set it, but - // LightClaw daemon is always Node — defensive `?? url` fallback for - // mocks / non-redirect cases. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const finalUrl: string = (response as any)?.request?.res?.responseUrl ?? url - - return { - status: response.status, - finalUrl, - contentType, - bytes: Buffer.from(response.data), + let currentUrl = url + let initialHost: string + try { + initialHost = new URL(url).hostname.toLowerCase() + } catch { + throw new Error(`WebFetch: invalid URL ${url}`) + } + + // Walk the redirect chain manually. axios sees `maxRedirects:0` per + // request, and validateStatus accepts 3xx so the response surfaces + // instead of throwing. Each iteration either: + // - 2xx → return the body + // - 3xx → validate Location, advance currentUrl, continue + // - 4xx/5xx → axios throws via its own default validateStatus (we + // widened to accept 3xx but kept 4xx/5xx as errors) + const redirectChain: string[] = [] + for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) { + // Retry transient network failures (socket reset / TLS-handshake abort — + // the 2026-05-14 proxy-blip dogfood — plus upstream 502/503/504, which + // axios's default validateStatus surfaces as AxiosError with + // `.response.status`). 4xx, axios timeout (ECONNABORTED), and abort are + // NOT retried; see web-retry.ts. + const response = await withWebRetry( + () => + httpGetFn(currentUrl, { + signal, + timeout: timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS, + maxRedirects: 0, + maxContentLength: MAX_HTTP_CONTENT_LENGTH, + responseType: 'arraybuffer', + headers: BROWSER_HEADERS, + httpAgent: agent, + httpsAgent: agent, + proxy: false, + decompress: true, + // Accept 3xx so we can read the Location header ourselves. + // 4xx/5xx still throw via axios default error path so the + // WebFetch tool envelope catches them. + validateStatus: status => + (status >= 200 && status < 300) || (status >= 300 && status < 400), + }), + { label: 'WebFetch', signal }, + ) + + if (response.status >= 200 && response.status < 300) { + const contentTypeHeader = response.headers['content-type'] + const contentType = + typeof contentTypeHeader === 'string' + ? contentTypeHeader + : Array.isArray(contentTypeHeader) + ? contentTypeHeader[0] ?? '' + : '' + return { + status: response.status, + finalUrl: currentUrl, + contentType, + bytes: Buffer.from(response.data), + redirectChain, + } + } + + // 3xx — read Location, validate, advance. + const locationHeader = response.headers.location + const location = + typeof locationHeader === 'string' + ? locationHeader + : Array.isArray(locationHeader) + ? locationHeader[0] + : undefined + if (!location) { + // 3xx without Location is malformed; throw the same axios-style + // message the WebFetch envelope expects. + throw new AxiosError( + `HTTP ${response.status} missing Location header at ${currentUrl}`, + ) + } + let nextUrl: string + try { + nextUrl = new URL(location, currentUrl).toString() + } catch { + throw new AxiosError( + `HTTP ${response.status} malformed Location ${location} at ${currentUrl}`, + ) + } + + // Build the chain entry BEFORE the safety checks so SSRF / cross-host + // errors include the attempted target in the chain for forensics. + redirectChain.push(nextUrl) + + // SSRF hard-block — mode-independent. + const ssrfReason = classifyRedirectTarget(nextUrl) + if (ssrfReason) { + throw new SsrfRedirectError([url, ...redirectChain], nextUrl, ssrfReason) + } + + // Cross-host check. Same hostname (case-insensitive) → follow. + const nextHost = new URL(nextUrl).hostname.toLowerCase() + const currentHost = new URL(currentUrl).hostname.toLowerCase() + if (nextHost !== currentHost) { + // Strict same-host rule. The initial hostname (initialHost) is what + // permission gated on; any host change is suspicious. The model + // should re-issue WebFetch on the new URL so its hostname goes + // through the normal permission flow. + throw new CrossHostRedirectError( + [url, ...redirectChain.slice(0, -1)], + currentHost, + nextUrl, + ) + } + + currentUrl = nextUrl } + + // Fell out of the loop without returning → exceeded MAX_REDIRECTS. + throw new RedirectLimitError([url, ...redirectChain]) +} + +/** Re-export for tests that need to assert on the initial-host comparison + * semantics directly. Public surface deliberately narrow — callers should + * go through {@link daemonFetchUrl}. */ +export const _internalsForTests = { + classifyRedirectTarget, + MAX_REDIRECTS, } diff --git a/src/tools/web-fetch.test.ts b/src/tools/web-fetch.test.ts index 5e0a0e0d..db4de02c 100644 --- a/src/tools/web-fetch.test.ts +++ b/src/tools/web-fetch.test.ts @@ -45,12 +45,14 @@ function stubFetch(opts: { contentType?: string status?: number finalUrl?: string + redirectChain?: string[] }): void { _setDaemonFetchUrlForTests(async () => ({ status: opts.status ?? 200, finalUrl: opts.finalUrl ?? 'https://example.com/', contentType: opts.contentType ?? 'text/plain; charset=utf-8', bytes: Buffer.from(opts.body, 'utf-8'), + redirectChain: opts.redirectChain ?? [], })) } @@ -234,6 +236,7 @@ describe('WebFetch tool — daemon-side migration regressions', () => { finalUrl: 'https://arxiv.org/pdf/2509.25721', contentType: 'application/pdf', bytes: pdfBytes, + redirectChain: [], })) const writes = new Map() const result = await webFetchTool.call( @@ -266,6 +269,7 @@ describe('WebFetch tool — daemon-side migration regressions', () => { finalUrl: 'https://arxiv.org/pdf/2604.28181', contentType: 'application/pdf', bytes: bigPdf, + redirectChain: [], })) const writes = new Map() const result = await webFetchTool.call( diff --git a/src/tools/web-fetch.ts b/src/tools/web-fetch.ts index ad7eba5d..7d5ec17f 100644 --- a/src/tools/web-fetch.ts +++ b/src/tools/web-fetch.ts @@ -13,7 +13,12 @@ import { } from './web-fetch-cache.js' import { textBodyToMarkdown } from './web-fetch-extract.js' import { deriveFilename, isBinaryContentType } from './web-fetch-filename.js' -import { daemonFetchUrl } from './web-fetch-http.js' +import { + CrossHostRedirectError, + daemonFetchUrl, + RedirectLimitError, + SsrfRedirectError, +} from './web-fetch-http.js' import { isPreapprovedUrl } from './web-fetch-preapproved.js' const DEFAULT_MAX_BYTES = 50_000 // helper exec output cap (schema default); admin can raise via input.maxBytes up to MAX_BYTES_HARD_CAP @@ -162,6 +167,46 @@ When a URL redirects to a different host, follow up with a new WebFetch on the r }) } } catch (err) { + // Redirect-guard family carries structured fields so the model gets + // an actionable recovery hint instead of the generic axios envelope. + // No permission card is involved here — these errors come out of + // the daemon-fetch layer. The model's recourse for CrossHost is to + // re-issue WebFetch on the final URL (which goes through normal + // per-hostname permission gating; auto-allowed in `acceptEdits` + // mode, card in default mode); for SsrfRedirect / RedirectLimit + // there is no recourse — those are hard blocks. + if (err instanceof SsrfRedirectError) { + return { + output: + `WebFetch failed (exit 1): redirect to non-public address ` + + `(${err.reason}) at ${err.blockedUrl}. SSRF guard refused to ` + + `follow. Chain: ${err.redirectChain.join(' → ')} (url: ${input.url})`, + isError: true, + } + } + if (err instanceof CrossHostRedirectError) { + // Pull the would-be final target out of the chain so the model has + // exactly the URL string it should call WebFetch on next. + const finalTarget = err.toUrl + return { + output: + `WebFetch failed (exit 1): cross-host redirect from ` + + `${err.fromHost} to ${finalTarget}. The daemon does not follow ` + + `cross-host redirects automatically. If this destination is ` + + `intended, call WebFetch on ${finalTarget} directly. ` + + `Chain: ${err.redirectChain.join(' → ')} → ${finalTarget} ` + + `(url: ${input.url})`, + isError: true, + } + } + if (err instanceof RedirectLimitError) { + return { + output: + `WebFetch failed (exit 1): redirect limit exceeded. Chain: ` + + `${err.redirectChain.join(' → ')} (url: ${input.url})`, + isError: true, + } + } // Mirror the Python helper's stderr envelope: `fetch failed: ` // wrapped in the WebFetch tool's `WebFetch failed (exit 1): ...` // shell. The error type (AxiosError / TimeoutError / DOMException