Skip to content

Fix SSRF bypass vectors and HTTPS 502 double-logging#44

Merged
JoshCap20 merged 6 commits into
mainfrom
fix/security-and-error-handling
Feb 25, 2026
Merged

Fix SSRF bypass vectors and HTTPS 502 double-logging#44
JoshCap20 merged 6 commits into
mainfrom
fix/security-and-error-handling

Conversation

@JoshCap20
Copy link
Copy Markdown
Owner

@JoshCap20 JoshCap20 commented Feb 25, 2026

Summary

  • Fix HTTPS 502 double-logging — The CONNECT handler returned Err(e) after sending a 502 response, causing the caller to log the same error again. Changed to return Ok(()) with an inline comment explaining the invariant.

  • Block IPv6 unique-local (fc00::/7) and link-local (fe80::/10) addresses — The IPv6 SSRF check only covered loopback and unspecified, missing the IPv6 equivalents of RFC 1918 private ranges.

  • Block IPv4-mapped IPv6 bypass (::ffff:0:0/96) — Attackers could encode private IPv4 addresses in IPv6 notation (e.g., ::ffff:127.0.0.1) to bypass SSRF protection. Now extracts and validates the embedded IPv4 address.

  • Close DNS TOCTOU gap in HTTP forwarding — The HTTP handler resolved DNS for SSRF verification, then reqwest resolved DNS independently. Fixed by building a per-host client pinned to the pre-verified addresses via reqwest::Client::builder().resolve().

  • Restore connection pooling on pinned clients — Extract base_client_builder() so both the static HTTP_CLIENT and per-host pinned clients share identical timeout, pooling, and keepalive settings.

  • Handle IPv6 zone IDs in SSRF checkfe80::1%eth0 failed to parse as an IpAddr, causing is_private_address to return false. Now strips the zone ID suffix before parsing.

Also simplifies is_private_address to delegate to is_private_ip after parsing, eliminating duplicated IPv4 check logic.

Test plan

  • test_is_private_ip_v6_unique_local — fc00::/7 and fe80::/10 blocked
  • test_is_private_ip_v4_mapped_v6 — ::ffff:private blocked, ::ffff:public allowed
  • test_is_private_address_v6_unique_local — string-based check covers unique-local
  • test_is_private_address_v4_mapped_v6 — string-based check covers IPv4-mapped
  • test_is_private_address_v6_zone_id — fe80::1%eth0 correctly detected as private
  • test_send_request_uses_resolved_addrs — proves reqwest uses pinned IPs, not re-resolving DNS
  • All 68 tests pass, 0 ignored
  • cargo fmt and cargo clippy clean

Closes #29, closes #32, closes #33, closes #34

The CONNECT handler was returning Err(e) after sending a 502 Bad
Gateway response when the upstream TCP connection failed. This caused
the caller in main.rs to log the error again, producing noisy
duplicate log lines.

Change return Err(e.into()) to return Ok(()) to match the pattern
already used by the SSRF and DNS rebinding error paths.

Closes #29
…n SSRF protection

The IPv6 path in is_private_ip only checked is_loopback() and
is_unspecified(), missing:
- Unique-local addresses (fc00::/7) — the IPv6 equivalent of
  RFC 1918 private ranges, allowing SSRF to internal networks
- Link-local addresses (fe80::/10)
- IPv4-mapped IPv6 addresses (::ffff:0:0/96) — allowing attackers to
  bypass SSRF protection by encoding private IPv4 addresses in IPv6
  notation (e.g., ::ffff:127.0.0.1)

Also simplify is_private_address to delegate to is_private_ip after
parsing, eliminating duplicated IPv4 check logic and ensuring both
functions have identical coverage.

Closes #32
Closes #33
…nning

The HTTP handler resolved DNS once for SSRF verification, then reqwest
resolved DNS independently when connecting. An attacker with a
short-TTL DNS record could return a public IP on the first resolution
(passing the SSRF check) and a private IP on the second (reaching
internal services).

Fix by passing the pre-verified resolved addresses into the request
and building a per-request reqwest client with .resolve() to pin the
hostname to those addresses. This matches the HTTPS handler which
already connects directly to verified addresses.

Closes #34
…ients

The TOCTOU fix built per-request clients with hardcoded timeouts and
no connection pooling settings. This caused every request to open a
fresh TCP connection, losing keepalive, pool reuse, and HTTP/2
multiplexing — a significant throughput regression.

Extract base_client_builder() so both HTTP_CLIENT and per-host pinned
clients share identical timeout, pooling, and keepalive settings.
Also clarify the defensive fallback path with a comment explaining
it is unreachable in normal proxy operation.
The ignored test required network access and ~75s for TCP timeout,
meaning it never ran in CI — providing false coverage. The one-line
fix (Err → Ok) is trivially correct by inspection and follows the
identical pattern used by the SSRF and DNS rebinding error paths
directly above it.

Replace the dead test with an inline comment explaining why the
return must be Ok(()) to prevent double-logging.
Rust's IpAddr parser rejects IPv6 addresses with zone ID suffixes
(e.g., "fe80::1%eth0"). This caused is_private_address to return
false for link-local addresses with zone IDs, bypassing the SSRF
check.

Strip the zone ID (everything after %) before parsing.
@JoshCap20 JoshCap20 merged commit cb1ecaa into main Feb 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant