Skip to content

web_fetch SSRF: IPv4-mapped IPv6 branch is dead code, and redirects are followed without revalidation #60

Description

@thegeorgepu

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

  1. 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.
  2. Drop the !host.includes(":") gate so IPv6 literals are validated too.
  3. Use redirect: "manual" and re-run preflightUrl + the DNS check on every hop, with a hop cap.
  4. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions