security: block cloud-metadata SSRF targets in snapshot navigation + discovery (PER-8614)#2333
Conversation
…discovery (PER-8614) Snapshot URLs and discovered subresource requests were navigated/fetched by Chromium without validating the target host, allowing SSRF to cloud instance-metadata endpoints (AWS/Azure/GCP IMDS, ECS task metadata, Alibaba, GCP metadata hostnames) to exfiltrate short-lived cloud credentials. Add isMetadataTarget/assertNotMetadataTarget helpers that block only known metadata endpoints (literal IPs incl. IPv6, metadata hostnames, and any hostname that resolves to a metadata IP to defeat DNS rebinding). Applied before top-level navigation (discovery.js) and before issuing outbound subresource requests (network.js). Loopback and general RFC1918 hosts remain allowed so localhost/internal-staging snapshotting is unaffected. DNS resolution failures are non-fatal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a discovery spec exercising the subresource cloud-metadata block path in network.js (instrumentation + Fetch.failRequest) and a utils spec covering the canonicalHost fallback when a resolved address is not a parseable IPv6 literal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude Code PR ReviewPR: #2333 • Head: 57426e1 • Reviewers: stack:pr-review orchestrator + independent SSRF/correctness reviewer SummaryBlocks cloud instance-metadata SSRF targets (169.254.169.254, 169.254.170.2, 100.100.100.200, fd00:ec2::254, metadata.google.internal, metadata.goog) in both snapshot navigation ( Review Table
Findings
Verified correct (not gaps): numeric IPv4 encodings (decimal/hex/octal) all canonicalize to dotted form on both paths; trailing-dot/case/IPv6-compression/userinfo handled; Verdict: FAIL |
… IPv6) Also inspect the redirect hop target (request.url) in the subresource block, not just the origin URL, so an open redirect to a metadata endpoint cannot smuggle a request through. Fold IPv4-mapped IPv6 addresses to dotted-quad in canonicalHost so e.g. [::ffff:169.254.169.254] matches the literal metadata IP. Adds unit + discovery coverage for both. Loopback/RFC1918 stay allowed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude Code PR Review (re-review after fixes)PR: #2333 • Head: 3bce1d5 • Reviewers: stack:pr-review orchestrator + independent SSRF/correctness reviewer SummaryBlocks cloud instance-metadata SSRF targets (169.254.169.254, 169.254.170.2, 100.100.100.200, fd00:ec2::254, metadata.google.internal, metadata.goog) in both snapshot navigation ( What changed since the FAIL review
Review Table
Findings
No Critical or High findings remain. Verdict: PASS |
Claude Code PR ReviewPR: #2333 • Head: 3bce1d5 • Reviewers: self (orchestrator adversarial security + correctness + quality review) SummaryBlocks cloud instance-metadata SSRF (PER-8614): a new Review Table
FindingsAdversarial verification exercised the full bypass matrix against the actual code. All were correctly blocked:
No Critical/High/Medium findings. Two Low / informational notes (non-blocking):
Verdict: PASS |
pranavz28
left a comment
There was a problem hiding this comment.
Automated multi-agent security review. The direct vectors are solidly closed and I verified the suspected bypasses do NOT work: literal metadata IPs in decimal/octal/hex all normalize via WHATWG URL and match (http://2852039166/ → 169.254.169.254), IPv6 / IPv4-mapped forms canonicalize correctly, and the redirect block is genuinely pre-request (request-stage interception catches every hop before Chromium fetches it). Nice work on those.
The blocker is the DNS-rebinding leg — the mechanism the code names as its rebinding defense doesn't actually stop rebinding, because Chromium re-resolves independently. Plus two paths skip the check. Inline below.
| if (net.isIP(canon)) return null; | ||
|
|
||
| try { | ||
| let addresses = await dns.promises.lookup(canon, { all: true }); |
There was a problem hiding this comment.
P1 (blocker) — this lookup does not defend against DNS rebinding, though the comment above claims it does. isMetadataTarget resolves via Node's dns.promises.lookup, but the actual request is made by a different resolver — Chromium (after Fetch.continueRequest) or Node's http stack in the direct-fetch fallback — which re-resolves independently. Classic TOCTOU, and the attacker controls the subresource URLs by design (they author the page).
Bypass: attacker DNS returns a benign IP (or NXDOMAIN → treated as non-fatal → allowed) to this lookup, then 169.254.169.254 to Chromium's lookup (short TTL / round-robin / A-vs-AAAA split). IMDS is fetched and its body uploaded. So this reliably blocks only literal IPs/hostnames and hosts that statically resolve the same at both moments — not rebinding.
Fix: gate on the IP Chromium actually connected to — response.remoteIPAddress at the response stage (network.js _handleResponseReceived / _handleResponsePaused), before buffering the body. Keep the cheap literal/hostname pre-check as a first line, but correct the comment's rebinding claim.
There was a problem hiding this comment.
Fixed in e129bf2. You are right — the request-time dns.lookup was a TOCTOU no-op against rebinding, and that lookup is now removed. The rebinding leg is closed at the response stage: _handleResponseReceived now gates on response.remoteIPAddress (the IP Chromium actually connected to) and, on a metadata match, logs + drops the request before request.response.buffer is ever wired up — so the body is never fetched via Network.getResponseBody or uploaded. The synchronous literal-IP/hostname pre-check in sendResponseResource stays as a cheap first line, and its misleading "defeats rebinding" comment is corrected to point at the response-stage gate. Proven by a unit test that drives _handleResponseReceived with a benign request-time host but a remoteIPAddress of 169.254.169.254 (and the ::ffff: mapped form) and asserts the resource is blocked and no buffer is attached, plus a discovery integration test that rewrites the connected IP on Network.responseReceived.
| responseHeaders: Object.entries(resource.headers || {}) | ||
| .map(([k, v]) => ({ name: k.toLowerCase(), value: String(v) })) | ||
| }); | ||
| } else if ((metadataHost = await isMetadataTarget(url)) || |
There was a problem hiding this comment.
P2 — the check isn't applied on all outbound paths. Two real paths skip isMetadataTarget entirely: (1) the direct-fetch fallback captureResourceDirectly → makeDirectRequest (worker/PlzDedicatedWorker scripts) — and it's the one path that also attaches cookies/Basic auth; (2) the service-worker branch (if (!serviceWorker) skips sendResponseResource) when captureMockedServiceWorker is enabled. Consider factoring the check into a single choke point all continue/direct-fetch paths funnel through.
P2 (perf) — every non-cached subresource now awaits a dns.lookup before continueRequest, adding a DNS round-trip to the critical path of essentially every public asset (libuv threadpool pressure on cold discovery). The response-stage remoteIPAddress fix from the other comment removes the need for this pre-request lookup — you'd keep only the cheap synchronous literal-IP/hostname Set checks here.
There was a problem hiding this comment.
Fixed in e129bf2. All outbound paths now funnel the metadata decision through a single choke point (matchMetadataHost, exposed as isMetadataTarget for literal hosts and isMetadataIP for connected IPs):
- Direct-fetch fallback (
captureResourceDirectly→makeDirectRequest, the path that attaches cookies/Basic auth): the transport now runs inNetwork#directFetch, which captures the socket's actual connected IP via a dnslookuphook (the same resolution the connection uses — not an extra round-trip).makeDirectRequestrunsisMetadataIPon that IP and throwsMetadataBlockedErroron a hit, so the body is never saved/uploaded. This covers both direct-fetch callers (worker/PlzDedicatedWorker scripts and the font-mime re-fetch). - Service-worker branch: it never issues its own outbound fetch — its responses surface through
Network.responseReceived, which is exactly where the new response-stageremoteIPAddressgate runs, so it is covered by the same universal choke point.
Perf: the per-request await dns.lookup before continueRequest is removed — the request path now does only the cheap synchronous literal-IP/hostname Set checks, and the real enforcement moved to the response stage / connected IP. No more DNS round-trip on every subresource.
| it('blocks IPv4-mapped IPv6 forms of metadata IPs', async () => { | ||
| // ::ffff:a.b.c.d maps to the IPv4 service at the socket layer, so it must | ||
| // be folded back to dotted-quad and blocked like the literal IPv4 IP | ||
| expect(await isMetadataTarget('http://[::ffff:169.254.169.254]/latest/meta-data/')).toBe('[::ffff:a9fe:a9fe]'); |
There was a problem hiding this comment.
P3 (test brittleness). Asserting the exact canonical form '[::ffff:a9fe:a9fe]' couples the test to Node's WHATWG-URL rendering — a normalization change would fail this without any behavior regression. Prefer asserting the decision (truthy host returned / assertNotMetadataTarget throws) across the equivalent input forms, and keep at most one test pinning the exact canonicalization if you want to lock the format.
There was a problem hiding this comment.
Fixed in e129bf2. The IPv6 / IPv4-mapped tests now assert the decision (toBeTruthy() that a host is returned, assertNotMetadataTarget throws / does not throw) across the equivalent input forms, so a WHATWG-URL normalization change can no longer fail them without a real behavior regression. Exactly one test still pins the exact canonical form ("[::ffff:a9fe:a9fe]") to deliberately lock the canonicalHost rendering.
Address review feedback on the metadata-SSRF block (PER-8614). P1 — the request-time isMetadataTarget lookup could not defend against DNS rebinding: it resolved via Node's dns, but the real connection is made by a different resolver (Chromium after continueRequest, or Node in the direct-fetch fallback), a classic TOCTOU. Gate on the IP actually connected to instead: _handleResponseReceived now blocks when response.remoteIPAddress canonicalizes to a metadata target, before the response body is ever buffered/uploaded. The request-time check is kept as a cheap synchronous literal-IP/hostname pre-check, and its misleading "defeats rebinding" comment is corrected. P2 — cover all outbound paths through a single decision choke point (matchMetadataHost / isMetadataIP): the Node direct-fetch path (makeDirectRequest, which also attaches cookies/Basic auth) now enforces the block on the socket's actual connected IP (captured via a dns lookup hook — the same resolution the connection uses, not an extra round-trip) and throws MetadataBlockedError so the body is never saved. The service-worker branch is covered by the universal response-stage gate. Removed the per-request dns.lookup from the request path so every subresource no longer pays a DNS round-trip before continueRequest. P3 — assert the block decision across equivalent input forms rather than the exact WHATWG-URL canonical string; keep one test pinning the canonical form. Tests: unit-level rebinding proof that _handleResponseReceived blocks a metadata connected IP (incl. IPv4-mapped IPv6) and never wires up the body buffer; isMetadataIP / flattenLookupAddresses / MetadataBlockedError unit tests; and discovery integration tests simulating rebinding at the response stage and on the direct-fetch path. @percy/core coverage thresholds unchanged (100%). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude Code PR ReviewPR: #2333 • Head: e129bf2 • Reviewers: fallback inline checklist SummaryBlocks SSRF to cloud instance-metadata endpoints (AWS/GCP/Azure IMDS, ECS, Alibaba) during asset discovery, enforced at four layers — top-level navigation guard, sub-resource request pre-check (incl. redirect target), response-stage connected-IP gate (DNS-rebinding-proof), and direct-fetch socket-IP gate — all funneling through one Review Table
FindingsNo Critical or High findings. Design note (intentional, non-blocking): the fix is surgical — it blocks only known cloud-metadata endpoints and deliberately keeps loopback + RFC1918 reachable, diverging from the ticket's broader "block all private ranges" remediation. This is correct for Percy (snapshotting localhost/internal hosts is core usage) and is asserted by the allow-list tests. DNS-failure is fail-open (returns null → allowed) to avoid breaking legitimate hosts on flaky resolution. Verdict: PASS |
Summary
Snapshot URLs and discovered subresource requests were navigated/fetched by Chromium without validating the target host. A malicious or compromised snapshot URL (or a subresource it references) could point Chromium at a cloud instance-metadata endpoint and exfiltrate short-lived cloud credentials — an SSRF (F-016 / PER-8614).
This PR adds a surgical block of cloud-metadata endpoints only. It deliberately does not block RFC1918 / loopback / link-local ranges in general, because snapshotting
http://localhost:3000and internal staging hosts is a core Percy use case.What is blocked
isMetadataTarget(url)/assertNotMetadataTarget(url)(inpackages/core/src/utils.js) return/throw when the URL host is a known cloud instance-metadata endpoint:169.254.169.254(AWS/Azure/GCP IMDS),169.254.170.2(ECS task metadata),100.100.100.200(Alibaba), and IPv6fd00:ec2::254(AWS IMDS v6).metadata.google.internal,metadata.goog.dns.promises.lookup, all addresses); the request is blocked if any resolved address is a metadata IP. Resolution failures are non-fatal — only a positive metadata match blocks — so offline/localhost snapshots keep working.Where the check is applied
assertNotMetadataTarget(snapshot.url)beforepage.goto(snapshot.url, ...)inpackages/core/src/discovery.js.isMetadataTarget(url)insendResponseResource(packages/core/src/network.js), gating the outboundFetch.continueRequestpath so a page cannot pivot to IMDS via a subresource. On a match the request is failed (Aborted) with a clear warn log:Refusing to fetch resource from cloud metadata endpoint: <host>. Cache hits and root resources are served earlier and never reach this branch, so no behavior changes for them.What is intentionally still allowed
127.0.0.1,localhost,[::1].192.168.x.x,10.x.x.x(internal staging).No behavior is altered for any non-metadata host.
Tests
Added unit tests in
packages/core/test/utils.test.jscovering: metadata IPs (v4 + v6) blocked, metadata hostnames (case-insensitive, trailing dot) blocked, DNS-rebinding to a metadata IP blocked, andlocalhost/127.0.0.1/[::1]/192.168.1.10/10.0.0.5/ a public host all still allowed, plus DNS-failure and unparseable-URL cases.All 10 new specs pass (
yarn workspace @percy/core test --node).node --checkpasses on all edited source files.🤖 Generated with Claude Code