1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
14use std::sync::Once;
15
16use url::{Host, Url};
17
18const ALLOW_PRIVATE_ENV: &str = "WEBFETCH_ALLOW_PRIVATE";
20
21static ALLOW_PRIVATE_WARNING: Once = Once::new();
22
23pub fn allow_private() -> bool {
29 let enabled = matches!(
30 std::env::var(ALLOW_PRIVATE_ENV).ok().as_deref(),
31 Some("1") | Some("true") | Some("TRUE")
32 );
33 if enabled {
34 ALLOW_PRIVATE_WARNING.call_once(|| {
35 eprintln!(
36 "warning: {ALLOW_PRIVATE_ENV} is set — SSRF guard disabled; \
37 private, loopback, and metadata IPs are reachable"
38 );
39 });
40 }
41 enabled
42}
43
44pub fn is_blocked_ip(ip: IpAddr) -> bool {
49 match ip {
50 IpAddr::V4(v4) => is_blocked_ipv4(v4),
51 IpAddr::V6(v6) => is_blocked_ipv6(v6),
52 }
53}
54
55fn is_blocked_ipv4(ip: Ipv4Addr) -> bool {
56 let o = ip.octets();
57 ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_broadcast() || ip.is_unspecified() || ip.is_multicast() || ip.is_documentation() || o[0] == 0 || (o[0] == 100 && (o[1] & 0xc0) == 64) || (o[0] == 192 && o[1] == 0 && o[2] == 0) || (o[0] == 198 && (o[1] & 0xfe) == 18) || o[0] >= 240 }
70
71fn is_blocked_ipv6(ip: Ipv6Addr) -> bool {
72 if let Some(v4) = ip.to_ipv4_mapped() {
74 return is_blocked_ipv4(v4);
75 }
76 if let Some(v4) = ip.to_ipv4() {
77 return is_blocked_ipv4(v4);
79 }
80 let seg = ip.segments();
81
82 if let Some(v4) = embedded_ipv4(ip) {
86 if is_blocked_ipv4(v4) {
87 return true;
88 }
89 }
90
91 ip.is_loopback()
92 || ip.is_unspecified()
93 || ip.is_multicast()
94 || (seg[0] & 0xffc0) == 0xfe80 || (seg[0] & 0xfe00) == 0xfc00 || (seg[0] == 0x2001 && seg[1] == 0x0db8) }
98
99fn embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
105 let seg = ip.segments();
106 let from = |hi: u16, lo: u16| Some(Ipv4Addr::from(((hi as u32) << 16) | lo as u32));
107
108 if seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2] == 0 && seg[3] == 0 && seg[4] == 0 {
110 return from(seg[6], seg[7]);
111 }
112 if seg[0] == 0x2002 {
114 return from(seg[1], seg[2]);
115 }
116 None
117}
118
119const BLOCKED_PORTS: [u16; 21] = [
126 22, 23, 25, 53, 69, 110, 119, 135, 137, 139, 143, 445, 465, 587, 993, 995, 1433, 3306, 5432, 6379, 11211, ];
148
149#[derive(Debug)]
151pub struct BlockedUrl(pub String);
152
153impl std::fmt::Display for BlockedUrl {
154 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 write!(f, "blocked URL: {}", self.0)
156 }
157}
158
159impl std::error::Error for BlockedUrl {}
160
161fn check_scheme(url: &Url) -> Result<(), BlockedUrl> {
165 match url.scheme() {
166 "http" | "https" => Ok(()),
167 other => Err(BlockedUrl(format!("scheme `{other}` not allowed"))),
168 }
169}
170
171fn check_port(url: &Url) -> Result<(), BlockedUrl> {
173 match url.port() {
174 Some(port) if BLOCKED_PORTS.contains(&port) => Err(BlockedUrl(format!(
175 "port {port} is not an HTTP service and is not fetchable"
176 ))),
177 _ => Ok(()),
178 }
179}
180
181pub async fn validate_url(url: &Url) -> Result<Vec<std::net::SocketAddr>, BlockedUrl> {
193 check_scheme(url)?;
194 check_port(url)?;
195
196 if allow_private() {
197 return Ok(Vec::new());
198 }
199
200 let host = url
201 .host()
202 .ok_or_else(|| BlockedUrl(format!("no host in {url}")))?;
203
204 match host {
205 Host::Ipv4(ip) => {
206 if is_blocked_ip(IpAddr::V4(ip)) {
207 return Err(BlockedUrl(format!("host IP {ip} is not public")));
208 }
209 Ok(Vec::new())
210 }
211 Host::Ipv6(ip) => {
212 if is_blocked_ip(IpAddr::V6(ip)) {
213 return Err(BlockedUrl(format!("host IP {ip} is not public")));
214 }
215 Ok(Vec::new())
216 }
217 Host::Domain(domain) => validate_domain(url, domain).await,
218 }
219}
220
221async fn validate_domain(url: &Url, domain: &str) -> Result<Vec<std::net::SocketAddr>, BlockedUrl> {
222 let lower = domain.to_ascii_lowercase();
224 if lower == "localhost" || lower.ends_with(".localhost") {
225 return Err(BlockedUrl(format!("host `{domain}` is local")));
226 }
227
228 let port = url
229 .port_or_known_default()
230 .ok_or_else(|| BlockedUrl(format!("no port for {url}")))?;
231
232 let addrs: Vec<_> = tokio::net::lookup_host((domain, port))
235 .await
236 .map_err(|e| BlockedUrl(format!("cannot resolve `{domain}`: {e}")))?
237 .collect();
238
239 if addrs.is_empty() {
240 return Err(BlockedUrl(format!("`{domain}` resolved to no addresses")));
241 }
242 for addr in &addrs {
243 if is_blocked_ip(addr.ip()) {
244 return Err(BlockedUrl(format!(
245 "`{domain}` resolves to non-public IP {}",
246 addr.ip()
247 )));
248 }
249 }
250 Ok(addrs)
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 fn blocked(s: &str) -> bool {
258 is_blocked_ip(s.parse::<IpAddr>().unwrap())
259 }
260
261 #[test]
262 fn blocks_loopback_and_private_and_metadata() {
263 assert!(blocked("127.0.0.1"));
264 assert!(blocked("10.0.0.1"));
265 assert!(blocked("172.16.5.4"));
266 assert!(blocked("192.168.1.1"));
267 assert!(blocked("169.254.169.254")); assert!(blocked("100.64.0.1")); assert!(blocked("0.0.0.0"));
270 assert!(blocked("255.255.255.255"));
271 assert!(blocked("224.0.0.1")); assert!(blocked("240.0.0.1")); }
274
275 #[test]
276 fn blocks_ipv6_local_and_mapped() {
277 assert!(blocked("::1")); assert!(blocked("::")); assert!(blocked("fe80::1")); assert!(blocked("fc00::1")); assert!(blocked("::ffff:127.0.0.1")); assert!(blocked("::ffff:169.254.169.254")); }
284
285 #[test]
286 fn allows_public() {
287 assert!(!blocked("1.1.1.1"));
288 assert!(!blocked("8.8.8.8"));
289 assert!(!blocked("93.184.216.34")); assert!(!blocked("2606:4700:4700::1111")); }
292
293 #[test]
296 fn blocks_ipv4_embedded_in_transition_addresses() {
297 assert!(blocked("64:ff9b::169.254.169.254")); assert!(blocked("64:ff9b::a00:1")); assert!(blocked("2002:a9fe:a9fe::1")); assert!(blocked("2002:7f00:1::1")); assert!(!blocked("64:ff9b::8.8.8.8"));
303 assert!(!blocked("2002:0808:0808::1")); }
305
306 #[tokio::test]
307 async fn rejects_non_http_ports() {
308 for target in [
309 "http://example.com:22/",
310 "http://example.com:6379/",
311 "https://example.com:3306/",
312 "http://example.com:25/",
313 ] {
314 let url = Url::parse(target).unwrap();
315 assert!(
316 validate_url(&url).await.is_err(),
317 "{target} should be blocked"
318 );
319 }
320 }
321
322 #[test]
323 fn allows_ordinary_http_ports() {
324 for target in [
326 "http://example.com/",
327 "https://example.com/",
328 "http://example.com:8080/",
329 "http://example.com:3000/",
330 "https://example.com:8443/",
331 ] {
332 assert!(check_port(&Url::parse(target).unwrap()).is_ok(), "{target}");
333 }
334 }
335
336 #[tokio::test]
337 async fn rejects_non_http_scheme() {
338 let url = Url::parse("file:///etc/passwd").unwrap();
339 assert!(validate_url(&url).await.is_err());
340 let url = Url::parse("ftp://example.com/x").unwrap();
341 assert!(validate_url(&url).await.is_err());
342 }
343
344 #[tokio::test]
345 async fn rejects_literal_metadata_ip_url() {
346 let url = Url::parse("http://169.254.169.254/latest/meta-data/").unwrap();
347 assert!(validate_url(&url).await.is_err());
348 }
349
350 #[tokio::test]
351 async fn rejects_localhost_name() {
352 let url = Url::parse("http://localhost:8080/admin").unwrap();
353 assert!(validate_url(&url).await.is_err());
354 }
355
356 #[test]
361 fn scheme_check_is_independent_of_the_env_opt_out() {
362 for bad in ["file:///etc/passwd", "ftp://example.com/x", "gopher://x/"] {
363 assert!(check_scheme(&Url::parse(bad).unwrap()).is_err(), "{bad}");
364 }
365 assert!(check_scheme(&Url::parse("https://example.com").unwrap()).is_ok());
366 }
367
368 #[tokio::test]
372 async fn rejects_redirect_target_to_private_ip() {
373 for target in [
374 "http://127.0.0.1/internal",
375 "http://10.0.0.1/admin",
376 "http://192.168.1.1/",
377 "http://169.254.169.254/latest/meta-data/",
378 ] {
379 let url = Url::parse(target).unwrap();
380 assert!(
381 validate_url(&url).await.is_err(),
382 "redirect target {target} should be blocked"
383 );
384 }
385 }
386}