Skip to content

fix: SSRF IPv6 bypass and Content-Disposition header injection#160

Merged
user1303836 merged 1 commit intomainfrom
fix/security-ssrf-ipv6-header-injection
Mar 13, 2026
Merged

fix: SSRF IPv6 bypass and Content-Disposition header injection#160
user1303836 merged 1 commit intomainfrom
fix/security-ssrf-ipv6-header-injection

Conversation

@user1303836
Copy link
Owner

Summary

  • SSRF IPv6 bypass (bug: SSRF protection missing IPv6 private range checks #140): is_private_ip() only checked IPv6 loopback and unspecified. Attackers could bypass SSRF protection using ULA (fc00::/7), link-local (fe80::/10), multicast (ff00::/8), or IPv4-mapped IPv6 addresses (::ffff:10.0.0.1). Added comprehensive checks for all private IPv6 ranges.
  • Content-Disposition header injection (security: Content-Disposition header injection in export download #136): download_export() constructed the Content-Disposition header using unsanitized dataset and format values. Added a sanitizer that strips all characters except [a-zA-Z0-9_-] before embedding them in the header, preventing HTTP response splitting via CRLF injection.

Test plan

  • cargo fmt --all --check passes
  • cargo clippy --workspace --all-targets -- -D warnings passes
  • cargo test --workspace passes (292 pass; 1 pre-existing failure in test_semaphore_limits_concurrent_jobs unrelated to this change)
  • New unit tests for IPv6 ULA, link-local, multicast, IPv4-mapped, and public addresses
  • New unit test for Content-Disposition sanitization with CRLF and special character injection
  • Pre-existing test_is_private_ip and test_export_download_* tests still pass

Closes #140, closes #136

🤖 Generated with Claude Code

Add comprehensive IPv6 private range checks to is_private_ip() to
block SSRF via ULA (fc00::/7), link-local (fe80::/10), multicast
(ff00::/8), and IPv4-mapped IPv6 addresses. Sanitize dataset and
format values in Content-Disposition header to prevent HTTP response
splitting via CRLF injection.

Closes #140, closes #136

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Owner Author

@user1303836 user1303836 left a comment

Choose a reason for hiding this comment

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

Review: SSRF IPv6 Bypass and Content-Disposition Header Injection

Content-Disposition Fix (Issue #136) -- Looks Good

The sanitization approach is correct and well-tested:

  • The [a-zA-Z0-9_-] allowlist is appropriately restrictive
  • The test covers CRLF injection and verifies the sanitized output
  • job_id is a Uuid (safe by construction), so only dataset and format need sanitization
  • Upstream validation (validate_dataset_name restricts to [a-zA-Z0-9_], ExportFormat is an enum) means this is defense-in-depth, which is the right posture for security-sensitive header construction
  • Even without this fix, the http crate's HeaderValue::from_str would reject CRLF (returning a 500 via the AppError::internal fallback), so the actual risk was denial-of-service rather than header injection -- but the fix correctly converts that 500 into proper behavior

No issues here.

SSRF IPv6 Fix (Issue #140) -- Has a Bypass

The ULA, link-local, and multicast bitmask checks are correct (I verified the math). However, there is a remaining bypass:

IPv4-compatible addresses (::x.x.x.x) bypass the check.

The PR uses v6.to_ipv4_mapped() which only matches ::ffff:x.x.x.x. It does NOT match IPv4-compatible addresses like ::10.0.0.1 (which normalizes to ::a00:1). An attacker can use http://[::10.0.0.1]/ to reach 10.0.0.1 on the internal network, bypassing all private IP checks.

Proof:

::10.0.0.1  -> to_ipv4_mapped() = None   (BYPASS!)
::10.0.0.1  -> to_ipv4()        = Some(10.0.0.1)
::ffff:10.0.0.1 -> to_ipv4_mapped() = Some(10.0.0.1) (correctly caught)

Fix: Replace to_ipv4_mapped() with to_ipv4() on line 542. The to_ipv4() method covers BOTH IPv4-mapped (::ffff:x.x.x.x) AND IPv4-compatible (::x.x.x.x) addresses. While IPv4-compatible addresses are deprecated (RFC 4291), they still parse and route on many systems.

// Change this:
|| v6.to_ipv4_mapped().is_some_and(|v4| {

// To this:
|| v6.to_ipv4().is_some_and(|v4| {

And update the comment from "IPv4-mapped" to "IPv4-mapped and IPv4-compatible" accordingly.

Add a test case:

assert!(is_private_ip("::10.0.0.1"));    // IPv4-compatible
assert!(is_private_ip("::127.0.0.1"));   // IPv4-compatible loopback
assert!(!is_private_ip("::8.8.8.8"));    // IPv4-compatible public (still allowed)

Lower-priority gaps (not blocking, but worth tracking):

  1. 6to4 addresses (2002::/16): 2002:0a00:0001:: embeds 10.0.0.1 in segments 1-2. These are not caught by any check. 6to4 is largely deprecated and relay availability is declining, so this is low severity but worth a follow-up issue.

  2. Teredo addresses (2001:0000::/32): These embed IPv4 addresses in the last 32 bits (XOR'd). Also low severity given Teredo's declining use.

Minor style note (non-blocking): The methods is_unique_local(), is_unicast_link_local(), and is_multicast() are now stable on Ipv6Addr (stabilized in Rust 1.84.0). The manual bitmask checks are equivalent and correct, but the std methods would be more idiomatic and self-documenting. This is a nice-to-have, not a blocker.

Summary

The Content-Disposition fix is correct and complete. The SSRF fix needs one change: replace to_ipv4_mapped() with to_ipv4() to close the IPv4-compatible address bypass. This is a one-line change plus test additions. Requesting this change before merge.

@user1303836 user1303836 merged commit 8537b28 into main Mar 13, 2026
4 checks 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

Development

Successfully merging this pull request may close these issues.

bug: SSRF protection missing IPv6 private range checks security: Content-Disposition header injection in export download

1 participant