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 ip.is_loopback()
82 || ip.is_unspecified()
83 || ip.is_multicast()
84 || (seg[0] & 0xffc0) == 0xfe80 || (seg[0] & 0xfe00) == 0xfc00 || (seg[0] == 0x2001 && seg[1] == 0x0db8) }
88
89#[derive(Debug)]
91pub struct BlockedUrl(pub String);
92
93impl std::fmt::Display for BlockedUrl {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 write!(f, "blocked URL: {}", self.0)
96 }
97}
98
99impl std::error::Error for BlockedUrl {}
100
101pub async fn validate_url(url: &Url) -> Result<Vec<std::net::SocketAddr>, BlockedUrl> {
111 if allow_private() {
112 return Ok(Vec::new());
113 }
114
115 match url.scheme() {
116 "http" | "https" => {}
117 other => return Err(BlockedUrl(format!("scheme `{other}` not allowed"))),
118 }
119
120 let host = url
121 .host()
122 .ok_or_else(|| BlockedUrl(format!("no host in {url}")))?;
123
124 match host {
125 Host::Ipv4(ip) => {
126 if is_blocked_ip(IpAddr::V4(ip)) {
127 return Err(BlockedUrl(format!("host IP {ip} is not public")));
128 }
129 Ok(Vec::new())
130 }
131 Host::Ipv6(ip) => {
132 if is_blocked_ip(IpAddr::V6(ip)) {
133 return Err(BlockedUrl(format!("host IP {ip} is not public")));
134 }
135 Ok(Vec::new())
136 }
137 Host::Domain(domain) => validate_domain(url, domain).await,
138 }
139}
140
141async fn validate_domain(url: &Url, domain: &str) -> Result<Vec<std::net::SocketAddr>, BlockedUrl> {
142 let lower = domain.to_ascii_lowercase();
144 if lower == "localhost" || lower.ends_with(".localhost") {
145 return Err(BlockedUrl(format!("host `{domain}` is local")));
146 }
147
148 let port = url
149 .port_or_known_default()
150 .ok_or_else(|| BlockedUrl(format!("no port for {url}")))?;
151
152 let addrs: Vec<_> = tokio::net::lookup_host((domain, port))
155 .await
156 .map_err(|e| BlockedUrl(format!("cannot resolve `{domain}`: {e}")))?
157 .collect();
158
159 if addrs.is_empty() {
160 return Err(BlockedUrl(format!("`{domain}` resolved to no addresses")));
161 }
162 for addr in &addrs {
163 if is_blocked_ip(addr.ip()) {
164 return Err(BlockedUrl(format!(
165 "`{domain}` resolves to non-public IP {}",
166 addr.ip()
167 )));
168 }
169 }
170 Ok(addrs)
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 fn blocked(s: &str) -> bool {
178 is_blocked_ip(s.parse::<IpAddr>().unwrap())
179 }
180
181 #[test]
182 fn blocks_loopback_and_private_and_metadata() {
183 assert!(blocked("127.0.0.1"));
184 assert!(blocked("10.0.0.1"));
185 assert!(blocked("172.16.5.4"));
186 assert!(blocked("192.168.1.1"));
187 assert!(blocked("169.254.169.254")); assert!(blocked("100.64.0.1")); assert!(blocked("0.0.0.0"));
190 assert!(blocked("255.255.255.255"));
191 assert!(blocked("224.0.0.1")); assert!(blocked("240.0.0.1")); }
194
195 #[test]
196 fn blocks_ipv6_local_and_mapped() {
197 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")); }
204
205 #[test]
206 fn allows_public() {
207 assert!(!blocked("1.1.1.1"));
208 assert!(!blocked("8.8.8.8"));
209 assert!(!blocked("93.184.216.34")); assert!(!blocked("2606:4700:4700::1111")); }
212
213 #[tokio::test]
214 async fn rejects_non_http_scheme() {
215 let url = Url::parse("file:///etc/passwd").unwrap();
216 assert!(validate_url(&url).await.is_err());
217 let url = Url::parse("ftp://example.com/x").unwrap();
218 assert!(validate_url(&url).await.is_err());
219 }
220
221 #[tokio::test]
222 async fn rejects_literal_metadata_ip_url() {
223 let url = Url::parse("http://169.254.169.254/latest/meta-data/").unwrap();
224 assert!(validate_url(&url).await.is_err());
225 }
226
227 #[tokio::test]
228 async fn rejects_localhost_name() {
229 let url = Url::parse("http://localhost:8080/admin").unwrap();
230 assert!(validate_url(&url).await.is_err());
231 }
232
233 #[tokio::test]
237 async fn rejects_redirect_target_to_private_ip() {
238 for target in [
239 "http://127.0.0.1/internal",
240 "http://10.0.0.1/admin",
241 "http://192.168.1.1/",
242 "http://169.254.169.254/latest/meta-data/",
243 ] {
244 let url = Url::parse(target).unwrap();
245 assert!(
246 validate_url(&url).await.is_err(),
247 "redirect target {target} should be blocked"
248 );
249 }
250 }
251}