| 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 |
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]
|