fix: SSRF IPv6 bypass and Content-Disposition header injection#160
fix: SSRF IPv6 bypass and Content-Disposition header injection#160user1303836 merged 1 commit intomainfrom
Conversation
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>
user1303836
left a comment
There was a problem hiding this comment.
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_idis aUuid(safe by construction), so onlydatasetandformatneed sanitization- Upstream validation (
validate_dataset_namerestricts to[a-zA-Z0-9_],ExportFormatis an enum) means this is defense-in-depth, which is the right posture for security-sensitive header construction - Even without this fix, the
httpcrate'sHeaderValue::from_strwould reject CRLF (returning a 500 via theAppError::internalfallback), 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):
-
6to4 addresses (2002::/16):
2002:0a00:0001::embeds10.0.0.1in 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. -
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.
Summary
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.download_export()constructed theContent-Dispositionheader using unsanitizeddatasetandformatvalues. 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 --checkpassescargo clippy --workspace --all-targets -- -D warningspassescargo test --workspacepasses (292 pass; 1 pre-existing failure intest_semaphore_limits_concurrent_jobsunrelated to this change)test_is_private_ipandtest_export_download_*tests still passCloses #140, closes #136
🤖 Generated with Claude Code