Two confirmed bypasses in the web_fetch SSRF guard (vinci/extensions/vinci-search.ts). Both are pre-existing on origin/main — they are not introduced by any open branch. Filed separately so they don't gate unrelated work.
1. The IPv4-mapped IPv6 branch never fires
isPrivateIp (vinci-search.ts:213-231) handles IPv4-mapped IPv6 with:
if (low.startsWith("::ffff:")) return isPrivateIp(low.slice(7)); // IPv4-mapped IPv6
That expects the dotted-quad form ::ffff:127.0.0.1. But WHATWG new URL() normalizes to the hex form first:
http://[::ffff:127.0.0.1]/ -> hostname: [::ffff:7f00:1]
So slice(7) yields 7f00:1, which matches the v4 regex on no branch, is not ::1/::, and does not start with fe80/fc/fd — the function returns false. Traced against the real predicate:
[::ffff:7f00:1] isPrivateIp -> false # this IS 127.0.0.1
[64:ff9b::7f00:1] isPrivateIp -> false # NAT64 to 127.0.0.1
127.0.0.1 isPrivateIp -> true
[::1] isPrivateIp -> true
Compounding it: the DNS re-check at :418 is gated on !host.includes(":"), so IPv6 literals skip resolution-time validation entirely. For an IPv6 literal, preflightUrl is the only control, and it is the one that fails.
Decimal, octal and hex encodings (2130706433, 0x7f000001, 0177.0.0.1, 127.1) are all correctly blocked — new URL normalizes them to dotted quad, which the v4 branch catches. The bug is specific to the IPv6 path.
2. Redirects are followed without per-hop revalidation
vinci-search.ts:435 issues fetch(pre.url, { redirect: "follow", ... }). The preflight and DNS checks run once, against the original host. Any allowed public host that responds 302 -> http://127.0.0.1/... or -> http://169.254.169.254/latest/meta-data/... is followed, and the body is returned to the model.
The comment at :232-233 acknowledges only "a small residual DNS-rebinding gap". This is a larger and considerably easier hole than the one disclosed.
Suggested fixes
- Normalize before matching: parse the address (e.g. compare against the canonical form, or handle the
::ffff:<hex>:<hex> shape) rather than assuming the dotted-quad spelling. Add 64:ff9b::/96 (NAT64) and, ideally, default-deny any IPv6 form the predicate does not positively recognise as public.
- Drop the
!host.includes(":") gate so IPv6 literals are validated too.
- Use
redirect: "manual" and re-run preflightUrl + the DNS check on every hop, with a hop cap.
- Pin each fix with a test that fails on the current code —
[::ffff:7f00:1] and a redirect-to-loopback are both cheap fixtures.
Severity in context
In the worker path this is low marginal risk: the unattended agent already holds bash with no egress allowlist (vinci/worker/README.md, "No network allowlist — the child can reach anything the box can reach"), so curl http://169.254.169.254/... is already one command away. web_fetch adds an attributable path to a capability the child already had.
Where it actually bites is a surface where web_fetch ships without bash — a read-only or restrictToReadOnly session, or any interactive profile with a narrowed tool set. There this guard is the only control, and it is bypassable.
Found during independent review of an unrelated branch; the two bypasses were reproduced against the real predicate before filing.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UNuX6G1j9mieQ1jcwpuAid
Two confirmed bypasses in the
web_fetchSSRF guard (vinci/extensions/vinci-search.ts). Both are pre-existing onorigin/main— they are not introduced by any open branch. Filed separately so they don't gate unrelated work.1. The IPv4-mapped IPv6 branch never fires
isPrivateIp(vinci-search.ts:213-231) handles IPv4-mapped IPv6 with:That expects the dotted-quad form
::ffff:127.0.0.1. But WHATWGnew URL()normalizes to the hex form first:So
slice(7)yields7f00:1, which matches the v4 regex on no branch, is not::1/::, and does not start withfe80/fc/fd— the function returns false. Traced against the real predicate:Compounding it: the DNS re-check at
:418is gated on!host.includes(":"), so IPv6 literals skip resolution-time validation entirely. For an IPv6 literal,preflightUrlis the only control, and it is the one that fails.Decimal, octal and hex encodings (
2130706433,0x7f000001,0177.0.0.1,127.1) are all correctly blocked —new URLnormalizes them to dotted quad, which the v4 branch catches. The bug is specific to the IPv6 path.2. Redirects are followed without per-hop revalidation
vinci-search.ts:435issuesfetch(pre.url, { redirect: "follow", ... }). The preflight and DNS checks run once, against the original host. Any allowed public host that responds302 -> http://127.0.0.1/...or-> http://169.254.169.254/latest/meta-data/...is followed, and the body is returned to the model.The comment at
:232-233acknowledges only "a small residual DNS-rebinding gap". This is a larger and considerably easier hole than the one disclosed.Suggested fixes
::ffff:<hex>:<hex>shape) rather than assuming the dotted-quad spelling. Add64:ff9b::/96(NAT64) and, ideally, default-deny any IPv6 form the predicate does not positively recognise as public.!host.includes(":")gate so IPv6 literals are validated too.redirect: "manual"and re-runpreflightUrl+ the DNS check on every hop, with a hop cap.[::ffff:7f00:1]and a redirect-to-loopback are both cheap fixtures.Severity in context
In the worker path this is low marginal risk: the unattended agent already holds
bashwith no egress allowlist (vinci/worker/README.md, "No network allowlist — the child can reach anything the box can reach"), socurl http://169.254.169.254/...is already one command away.web_fetchadds an attributable path to a capability the child already had.Where it actually bites is a surface where
web_fetchships withoutbash— a read-only orrestrictToReadOnlysession, or any interactive profile with a narrowed tool set. There this guard is the only control, and it is bypassable.Found during independent review of an unrelated branch; the two bypasses were reproduced against the real predicate before filing.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UNuX6G1j9mieQ1jcwpuAid