Skip to content

fix(security): SSRF guard for the LLM fallback fetch (H3) - #15

Merged
willamhou merged 1 commit into
masterfrom
sec/pr-c-ssrf-guard
Jun 24, 2026
Merged

fix(security): SSRF guard for the LLM fallback fetch (H3)#15
willamhou merged 1 commit into
masterfrom
sec/pr-c-ssrf-guard

Conversation

@willamhou

Copy link
Copy Markdown
Owner

PR-C of the security-hardening plan. Finding H3: the LLM fallback fetches a caller-supplied URL and attaches imported cookies with no host/IP validation (SSRF + cookie exfiltration).

core::net_guard (new, always compiled)

  • guard_and_resolve(url): http/https only → resolve host → reject if any resolved IP is non-public. is_blocked_ip covers v4 loopback/RFC1918/link-local (incl. 169.254.169.254 metadata)/CGNAT/benchmarking/reserved/broadcast/unspecified/multicast/documentation, and v6 loopback/unspecified/multicast/ULA(fc00::/7)/link-local(fe80::/10), plus IPv4-mapped & IPv4-compatible recursion and the 6to4 / Teredo / NAT64 transition prefixes (so 64:ff9b::169.254.169.254 etc. can't smuggle a blocked v4). Returns the validated IPs to pin.
  • host_matches_cookie_domain(host, domain) for cookie scoping.

browser.rs guarded fetch

  • Pins the validated IPs (.resolve, anti-DNS-rebinding), disables redirects (Policy::none()), disables system proxies (.no_proxy() — a proxy would otherwise re-resolve the host and bypass the pin), 30s timeout.
  • Fail-closed host allowlist (RELAIS_FALLBACK_ALLOW=host1,host2); unset ⇒ no host allowed.
  • Imported cookies are attached only when the request host matches the cookie's stored domain (CookieScope { domain, cookies }).

The fallback adapter is not registered by default, so this is fail-closed hardening for when it is enabled.

Tests

is_blocked_ip across v4 + v6 transition/mapped/compatible forms; public addresses allowed; loopback URL blocked offline; bad scheme rejected; cookie-domain scoping (incl. example.com.evil.com not matching example.com). cargo test --workspace green; core clippy clean.

Codex-reviewed; the two flagged HIGH bypasses (default system-proxy resolution, IPv6 transition/NAT64 forms) are fixed.

🤖 Generated with Claude Code

PR-C of the security-hardening plan.

- New always-compiled core::net_guard: guard_and_resolve() allows only http/https,
  resolves the host, and refuses if ANY resolved IP is non-public (loopback,
  RFC1918, link-local incl. 169.254.169.254 metadata, CGNAT, benchmarking, reserved;
  IPv6 ULA/link-local; IPv4-mapped/compatible recursion; 6to4/Teredo/NAT64
  transition prefixes). Returns the validated IPs to pin.
- browser.rs fetch_html: pins the validated IPs (.resolve, anti-rebinding), disables
  redirects, disables system proxies (.no_proxy, so a proxy can't re-resolve the
  host), 30s timeout, and a fail-closed host allowlist (RELAIS_FALLBACK_ALLOW).
  Imported cookies are sent only when the host matches the cookie's stored domain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@willamhou
willamhou merged commit dfec6b2 into master Jun 24, 2026
2 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8cad1e379b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

.no_proxy()
.timeout(Duration::from_secs(30));
for addr in &target.addrs {
builder = builder.resolve(&target.host, *addr);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Pin every validated address with resolve_to_addrs

When an allowlisted host has more than one validated A/AAAA record, ClientBuilder::resolve is a single-address override for a domain, so each iteration replaces the previous override and the client only tries the last address. In dual-stack or multi-A deployments where that last address is unreachable, the fallback fetch fails even though another validated address was available; pass the whole target.addrs slice via resolve_to_addrs instead.

Useful? React with 👍 / 👎.

Comment on lines +98 to +99
(s[0] & 0xfe00) == 0xfc00 // fc00::/7 unique-local
|| (s[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Block deprecated IPv6 site-local addresses

When an allowlisted hostname resolves to a deprecated IPv6 site-local address (fec0::/10), this predicate falls through because only ULA and link-local ranges are checked. Those addresses are non-public, so the new guard can allow DNS rebinding to internal IPv6 services on networks that still route site-local space; add an explicit fec0::/10 block.

Useful? React with 👍 / 👎.

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.

1 participant