Skip to content

fix(mcp): block full IPv6 link-local range fe80::/10 in SSRF check#1326

Merged
threepointone merged 2 commits intomainfrom
fix-1325
Apr 17, 2026
Merged

fix(mcp): block full IPv6 link-local range fe80::/10 in SSRF check#1326
threepointone merged 2 commits intomainfrom
fix-1325

Conversation

@threepointone
Copy link
Copy Markdown
Contributor

@threepointone threepointone commented Apr 17, 2026

Summary

isBlockedUrl in packages/agents/src/mcp/client.ts claimed to block fe80::/10 (per the inline comment) but the previous startsWith(\"fe80\") check only covered fe80::/16 — the first hextet is the first 16 bits, while /10 fixes only the first 10 bits. Addresses with first hextets fe81..febf are valid RFC 4291 §2.5.6 link-local and were slipping past the SSRF defense.

Fix: replace the literal prefix match with a regex anchored to the true /10 boundary (only hex digits 8, 9, a, b have high two bits 10), factor the IPv6 range checks into an isPrivateIPv6 helper for symmetry with isPrivateIPv4, and document the bit-math inline so this doesn't drift again.

// fe80::/10 — first hextet fe80..febf
const IPV6_LINK_LOCAL = /^fe[89ab][0-9a-f]/;

Impact

  • Severity: defense-in-depth. Workers runtimes are unlikely to route to unicast link-local addresses in practice, but the check should match its documented intent in every environment.
  • The existing 169.254.0.0/16 rule still covers the main cloud-metadata vector (EC2/GCE); this PR closes a narrower gap that was real per RFC but low-exploitability.

Tests

  • Regression (previously leaked, now blocked): fe81::1, feab::1, febf::1, FE8F::1 (case canonicalization).
  • Negative boundary (correctly NOT blocked by the link-local rule): fe7f::1, fec0::1.
  • Zone-ID path: the existing fe80::1%25eth0 test was passing via the "malformed URL" catch branch rather than the regex (zone IDs are rejected by the WHATWG URL parser in both Node and Workers). Split into two tests so the regex path is exercised unambiguously, and kept the zone-ID case as explicit malformed-URL coverage.

SSRF test count: 19 → 26 in the describe block. All 141 tests in client-manager.test.ts pass. npm run typecheck clean across all 73 projects. Oxlint clean.

Review notes (not in this PR)

While reviewing I found a few adjacent edge cases worth recording but deliberately out of scope:

  1. IPv4-compatible IPv6 (deprecated, RFC 4291 §2.5.5.1): [::192.168.1.1] canonicalizes to [::c0a8:101] and is not blocked. Near-zero exploitability (it's a pure IPv6 address in ::/96, not translated to IPv4 on modern stacks), but a theoretical gap worth a separate hardening PR if we want belt-and-suspenders.
  2. DNS rebinding and HTTP 3xx redirects to private IPs: fundamental limitations of a string-level URL check. MCP fetches use the default \"follow\" redirect mode. Separate design discussion if we want to address.
  3. Updated the inline comment on the ::ffff: IPv4-mapped branch: the WHATWG URL parser does not canonicalize hex-form tails to dotted form (I verified empirically), so the hex branch is the primary path, not defense-in-depth as the comment previously claimed.

Reported in #1325.

Test plan

  • npx vitest run src/tests/mcp/client-manager.test.ts -t \"SSRF URL validation\" → 26 pass
  • npx vitest run src/tests/mcp/client-manager.test.ts → 140 pass (full file)
  • npm run typecheck → 73/73 projects clean
  • npx oxlint on changed files → 0 warnings, 0 errors
  • npm run format applied
  • Changeset: patch on agents

Closes #1325

Made with Cursor


Open with Devin

isBlockedUrl documented its intent as fe80::/10 but the previous
startsWith("fe80") check only covered fe80::/16, letting valid
link-local addresses in the fe81::..febf:: range slip through.

Replace the prefix match with a regex anchored to the full /10 boundary
(first hextet fe80..febf), factor IPv6 range logic into isPrivateIPv6
for symmetry with isPrivateIPv4, and add regression tests for the
previously-leaking prefixes plus negative cases at the /10 boundary
(fe7f::, fec0::). The existing fe80::1%25eth0 test was passing via
the malformed-URL catch branch rather than the regex; split it into
two tests so the regex path is exercised unambiguously.

Fixes #1325

Made-with: Cursor
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 17, 2026

🦋 Changeset detected

Latest commit: 1d7cc8a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agents Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 4 additional findings.

Open in Devin Review

Introduce waitForOverlappingSubmits test helper and use it across message-concurrency tests to deterministically wait for overlapping submits before asserting which turns ran. Expose getOverlappingSubmitCountForTest() on SlowStreamAgent to return the agent's internal _latestOverlappingSubmitSequence so tests can observe overlapping submit counts (note: the first submit on an empty queue is not counted). Also increase chunkCount and chunkDelayMs for several test streams to reduce flakiness under CPU pressure.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 17, 2026

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@1326

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@1326

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@1326

hono-agents

npm i https://pkg.pr.new/hono-agents@1326

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@1326

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@1326

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@1326

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@1326

commit: 1d7cc8a

@threepointone threepointone merged commit d5042a9 into main Apr 17, 2026
2 checks passed
@threepointone threepointone deleted the fix-1325 branch April 17, 2026 07:14
@github-actions github-actions bot mentioned this pull request Apr 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP client: isBlockedUrl only blocks fe80::/16 despite comment stating fe80::/10

1 participant