From 55b3c2c00ce9028c80a8ded574a9d3a621388e0c Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:16:00 +0100 Subject: [PATCH] test(security): add HTTP hostname canonicalization edge-case tests (#522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(security): add HTTP hostname canonicalization edge-case tests Document that Rust's IpAddr::parse() rejects non-standard IP notations (octal, hex, decimal integer, zero-padded) which provides defense-in-depth against SSRF bypass attempts. Tests only — no production code changes. Closes #515 Co-Authored-By: Claude Opus 4.6 * style: apply rustfmt to providers/mod.rs Fix pre-existing formatting issue from main. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/tools/http_request.rs | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/tools/http_request.rs b/src/tools/http_request.rs index 0701f95..1d00253 100644 --- a/src/tools/http_request.rs +++ b/src/tools/http_request.rs @@ -749,4 +749,54 @@ mod tests { let _ = HttpRequestTool::redact_headers_for_display(&headers); assert_eq!(headers[0].1, "Bearer real-token"); } + + // ── SSRF: alternate IP notation bypass defense-in-depth ───────── + // + // Rust's IpAddr::parse() rejects non-standard notations (octal, hex, + // decimal integer, zero-padded). These tests document that property + // so regressions are caught if the parsing strategy ever changes. + + #[test] + fn ssrf_octal_loopback_not_parsed_as_ip() { + // 0177.0.0.1 is octal for 127.0.0.1 in some languages, but + // Rust's IpAddr rejects it — it falls through as a hostname. + assert!(!is_private_or_local_host("0177.0.0.1")); + } + + #[test] + fn ssrf_hex_loopback_not_parsed_as_ip() { + // 0x7f000001 is hex for 127.0.0.1 in some languages. + assert!(!is_private_or_local_host("0x7f000001")); + } + + #[test] + fn ssrf_decimal_loopback_not_parsed_as_ip() { + // 2130706433 is decimal for 127.0.0.1 in some languages. + assert!(!is_private_or_local_host("2130706433")); + } + + #[test] + fn ssrf_zero_padded_loopback_not_parsed_as_ip() { + // 127.000.000.001 uses zero-padded octets. + assert!(!is_private_or_local_host("127.000.000.001")); + } + + #[test] + fn ssrf_alternate_notations_rejected_by_validate_url() { + // Even if is_private_or_local_host doesn't flag these, they + // fail the allowlist because they're treated as hostnames. + let tool = test_tool(vec!["example.com"]); + for notation in [ + "http://0177.0.0.1", + "http://0x7f000001", + "http://2130706433", + "http://127.000.000.001", + ] { + let err = tool.validate_url(notation).unwrap_err().to_string(); + assert!( + err.contains("allowed_domains"), + "Expected allowlist rejection for {notation}, got: {err}" + ); + } + } }