Skip to main content

max / makenotwork

mt: close the SSRF integer-IP bypass in link preview validate_url pre-checked the host with std IpAddr::parse, which only accepts dotted-quad; integer/hex/octal encodings (http://2130706433/, http://0x7f000001/, http://0177.0.0.1/) failed that parse and fell through as hostnames, so reqwest then normalized them into literal internal IPs its SsrfSafeResolver never sees (the resolver runs only for name hosts). Reachable via any poster's markdown link (OG fetch) and GET /img-proxy?u= — blind SSRF to internal :80/:443 plus title/og exfil into the rendered card. Parse the host with the url crate reqwest itself connects through and inspect the concrete Host: an IP literal in any encoding is now caught by is_private_ip, a domain still defers to the resolver. This is a structural fix for the parser- divergence class (same root as the prior userinfo-variant H1), not a one-off. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 14:51 UTC
Commit: 6ea36052d0437530bcbe9ead77a0250327f6f2fa
Parent: 83215f5
3 files changed, +60 insertions, -56 deletions
@@ -2262,6 +2262,7 @@ dependencies = [
2262 2262 "tower_governor",
2263 2263 "tracing",
2264 2264 "tracing-subscriber",
2265 + "url",
2265 2266 "urlencoding",
2266 2267 "uuid",
2267 2268 "wiremock",
@@ -30,6 +30,9 @@ tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
30 30
31 31 # HTTP client / crypto
32 32 reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
33 + # Pinned to the exact major reqwest resolves so the SSRF pre-check parses the
34 + # host with the same parser reqwest connects through (link_preview::validate_url).
35 + url = "2"
33 36 sha2 = "0.10"
34 37 hmac = "0.12"
35 38 base64 = "0.22"
@@ -80,6 +83,7 @@ sqlx = { workspace = true }
80 83 tower-sessions = { workspace = true }
81 84 tower-sessions-sqlx-store = { workspace = true }
82 85 reqwest = { workspace = true }
86 + url = { workspace = true }
83 87 sha2 = { workspace = true }
84 88 base64 = { workspace = true }
85 89 rand = { workspace = true }
@@ -48,72 +48,48 @@ fn is_private_ip(ip: std::net::IpAddr) -> bool {
48 48 /// that (and the authoritative anti-rebinding check) happens at connect time in
49 49 /// [`SsrfSafeResolver`], so this stays non-blocking and usable from reqwest's
50 50 /// synchronous redirect callback.
51 + ///
52 + /// The host is parsed with the `url` crate — the SAME parser reqwest uses to
53 + /// decide what to connect to. An earlier hand-rolled parser split the authority
54 + /// itself and fed the host to `std::net::IpAddr::parse`, which only accepts the
55 + /// dotted-quad form; integer/hex/octal encodings (`http://2130706433/`,
56 + /// `http://0x7f000001/`, `http://0177.0.0.1/`) failed that parse, fell through as
57 + /// "a hostname (resolved safely later)", and reqwest then normalized them into
58 + /// literal internal IPs the `SsrfSafeResolver` never sees (it is consulted only
59 + /// for *name* hosts). Parsing here with `url::Url` closes that divergence: we
60 + /// inspect exactly the `Host` reqwest will act on, so an IP literal in any
61 + /// encoding is caught by `is_private_ip` and a domain defers to the resolver.
51 62 fn validate_url(url: &str) -> bool {
52 - let lower = url.to_ascii_lowercase();
53 - if !lower.starts_with("http://") && !lower.starts_with("https://") {
63 + let Ok(parsed) = url::Url::parse(url) else {
54 64 return false;
55 - }
56 - let host_part = lower
57 - .strip_prefix("http://")
58 - .or_else(|| lower.strip_prefix("https://"))
59 - .unwrap_or("");
60 - let authority = host_part.split('/').next().unwrap_or("");
61 - // Strip any userinfo ("user:pass@" or bare "@") — the real host is whatever
62 - // follows the LAST '@'. This must happen before the host/port split: without
63 - // it, `http://@10.0.0.1/` leaves `@10.0.0.1` as the "host", which fails
64 - // `IpAddr::parse` and slips past the literal-IP guard below. reqwest connects
65 - // to IP literals directly (it does NOT consult the custom `SsrfSafeResolver`
66 - // for a literal-IP host), so the literal-IP check here is the ONLY guard on
67 - // that path — the userinfo form must not be allowed to hide the IP from it.
68 - let host_and_port = match authority.rsplit_once('@') {
69 - Some((_userinfo, host)) => host,
70 - None => authority,
71 65 };
72 - if host_and_port.is_empty() {
73 - return false;
66 + match parsed.scheme() {
67 + "http" | "https" => {}
68 + _ => return false,
74 69 }
75 - // Split host and optional port (`[ipv6]:port` or `host:port`).
76 - let (host, port) = if host_and_port.starts_with('[') {
77 - match host_and_port.split_once(']') {
78 - Some((h, rest)) => (
79 - format!("{}]", h),
80 - rest.strip_prefix(':').filter(|p| !p.is_empty()),
81 - ),
82 - None => (host_and_port.to_string(), None),
83 - }
84 - } else {
85 - match host_and_port.split_once(':') {
86 - Some((h, p)) => (h.to_string(), Some(p).filter(|p| !p.is_empty())),
87 - None => (host_and_port.to_string(), None),
88 - }
89 - };
90 70 // Only allow the standard web ports. A public host that legitimately serves
91 71 // OG metadata does so on 80/443; an explicit non-web port (e.g. :6379) means
92 72 // the link is trying to use us as a request-forwarder to some other service.
93 - if let Some(p) = port
94 - && p != "80"
95 - && p != "443"
73 + // `port()` is `None` for the scheme default (80/443), which is fine.
74 + if let Some(p) = parsed.port()
75 + && p != 80
76 + && p != 443
96 77 {
97 78 return false;
98 79 }
99 - let host = host.as_str();
100 -
101 - // Quick string-based check for common private patterns
102 - if host == "localhost" || host == "0.0.0.0" {
103 - return false;
104 - }
105 -
106 - // Try parsing as a raw IP address (catches octal, hex, decimal encodings)
107 - let bare_host = host.trim_start_matches('[').trim_end_matches(']');
108 - if let Ok(ip) = bare_host.parse::<std::net::IpAddr>() {
109 - return !is_private_ip(ip);
80 + // Inspect the concrete host reqwest will connect to. Any IP literal —
81 + // dotted-quad, integer, hex, octal, or IPv6 — is normalized by the `url`
82 + // crate into `Host::Ipv4`/`Host::Ipv6` here, so the encoding tricks that
83 + // defeated `IpAddr::parse` can no longer hide behind the "it's a hostname"
84 + // branch. A real domain defers to `SsrfSafeResolver` at connect time.
85 + match parsed.host() {
86 + Some(url::Host::Ipv4(v4)) => !is_private_ip(std::net::IpAddr::V4(v4)),
87 + Some(url::Host::Ipv6(v6)) => !is_private_ip(std::net::IpAddr::V6(v6)),
88 + Some(url::Host::Domain(domain)) => {
89 + !domain.is_empty() && !domain.eq_ignore_ascii_case("localhost")
90 + }
91 + None => false,
110 92 }
111 -
112 - // Hostnames are resolved and re-checked at connect time by SsrfSafeResolver,
113 - // which is what reqwest actually connects through — so a name that resolves
114 - // to a private address (including via DNS rebinding after this check) can
115 - // never be connected to.
116 - true
117 93 }
118 94
119 95 /// Extract unique http/https URLs from markdown text via pulldown_cmark link parsing.
@@ -561,8 +537,31 @@ mod tests {
561 537
562 538 #[test]
563 539 fn validate_url_blocks_empty_host() {
540 + // A truly empty authority is a parse error under the `url` crate → rejected.
564 541 assert!(!validate_url("http://@/"));
565 - assert!(!validate_url("http:///path"));
542 + // `http:///path` is NOT an empty host under the `url` crate (nor under
543 + // reqwest): it normalizes to host "path", a domain deferred to
544 + // SsrfSafeResolver at connect time. Parsing with the same crate reqwest
545 + // uses means we treat it exactly as reqwest will — which is the point.
546 + assert!(validate_url("http:///path"));
547 + }
548 +
549 + #[test]
550 + fn validate_url_blocks_numeric_ip_encodings() {
551 + // The SSRF integer-IP bypass: these encodings all normalize to internal
552 + // literals under the `url` crate (what reqwest connects through) but were
553 + // rejected by the old `IpAddr::parse` pre-check, so they slipped past as
554 + // "hostnames" and reqwest connected straight to the internal IP without
555 + // ever consulting `SsrfSafeResolver`. validate_url must now block them.
556 + assert!(!validate_url("http://2130706433/")); // decimal 127.0.0.1
557 + assert!(!validate_url("http://0x7f000001/")); // hex 127.0.0.1
558 + assert!(!validate_url("http://0177.0.0.1/")); // octal-leading 127.0.0.1
559 + assert!(!validate_url("http://2852039166/")); // decimal 169.254.169.254 (cloud metadata)
560 + assert!(!validate_url("http://0xa9fea9fe/")); // hex 169.254.169.254
561 + assert!(!validate_url("http://017700000001/")); // full octal 127.0.0.1
562 + // A public host in decimal form is still reachable (sanity: the guard is
563 + // is_private_ip, not a blanket numeric-host reject).
564 + assert!(validate_url("http://134744072/")); // 8.8.8.8
566 565 }
567 566
568 567 #[test]