1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
2
3use crate::config::OpenApiSpecConfig;
4use crate::error::{OpenApiError, SsrfError};
5
6pub const PRIVATE_TLD_SUFFIXES: &[&str] =
7 &[".local", ".internal", ".lan", ".intranet", ".corp", ".home"];
8
9#[must_use]
10pub fn is_cgnat(ip: Ipv4Addr) -> bool {
11 let octets = ip.octets();
12 octets[0] == 100 && (64..=127).contains(&octets[1])
13}
14
15fn is_ipv6_link_local(ip: Ipv6Addr) -> bool {
16 (ip.segments()[0] & 0xffc0) == 0xfe80
17}
18
19fn is_ipv6_ula(ip: Ipv6Addr) -> bool {
20 (ip.segments()[0] & 0xfe00) == 0xfc00
21}
22
23fn is_ipv4_class_e(ip: Ipv4Addr) -> bool {
24 ip.octets()[0] >= 240
25}
26
27pub fn check_ip_not_private(ip: IpAddr, context: &str) -> Result<(), SsrfError> {
28 let normalized = match ip {
29 IpAddr::V6(v6) => v6
30 .to_ipv4_mapped()
31 .map(IpAddr::V4)
32 .unwrap_or(IpAddr::V6(v6)),
33 other => other,
34 };
35
36 let blocked = match normalized {
37 IpAddr::V4(v4) => {
38 v4.is_private()
39 || v4.is_loopback()
40 || v4.is_link_local()
41 || v4.is_unspecified()
42 || is_cgnat(v4)
43 || is_ipv4_class_e(v4)
45 }
46 IpAddr::V6(v6) => {
47 v6.is_loopback()
48 || v6.is_unspecified()
49 || is_ipv6_link_local(v6)
50 || is_ipv6_ula(v6)
51 || v6.is_multicast()
53 }
54 };
55
56 if blocked {
57 return Err(SsrfError::Blocked(format!(
58 "`{context}` resolves to a private, loopback, link-local, CGNAT, ULA, Class E, or multicast address {ip}; blocked to prevent SSRF"
59 )));
60 }
61
62 Ok(())
63}
64
65pub fn check_host_not_private(host: &str) -> Result<(), SsrfError> {
66 let host_lower = host.to_ascii_lowercase();
67 if host_lower == "localhost"
68 || host_lower.starts_with("127.")
69 || host_lower == "::1"
70 || host_lower.contains("::ffff:")
71 || host_lower == "0.0.0.0"
72 || PRIVATE_TLD_SUFFIXES
73 .iter()
74 .any(|suffix| host_lower.ends_with(suffix))
75 {
76 return Err(SsrfError::Blocked(format!(
77 "host `{host}` is a local/loopback/private address"
78 )));
79 }
80 Ok(())
81}
82
83#[must_use]
84pub fn redact_url(raw: &str) -> String {
85 match url::Url::parse(raw) {
86 Ok(mut url) => {
87 let _ = url.set_username("");
88 let _ = url.set_password(None);
89 url.set_query(None);
90 url.set_fragment(None);
91 url.to_string()
92 }
93 Err(_) => "<invalid-url>".to_string(),
94 }
95}
96
97pub fn parse_validated_https_url(url: &str) -> Result<url::Url, SsrfError> {
98 let redacted = redact_url(url);
99 let parsed = url::Url::parse(url)
100 .map_err(|error| SsrfError::InvalidUrl(format!("invalid URL `{redacted}`: {error}")))?;
101
102 if parsed.scheme() != "https" {
103 return Err(SsrfError::InvalidUrl(format!(
104 "URL `{redacted}` must use https to prevent SSRF"
105 )));
106 }
107 if !parsed.username().is_empty() || parsed.password().is_some() {
108 return Err(SsrfError::InvalidUrl(format!(
109 "URL `{redacted}` must not include userinfo"
110 )));
111 }
112 if parsed.query().is_some() || parsed.fragment().is_some() {
113 return Err(SsrfError::InvalidUrl(format!(
114 "URL `{redacted}` must not include query or fragment components"
115 )));
116 }
117
118 match parsed.host() {
119 Some(url::Host::Domain(domain)) => check_host_not_private(domain)?,
120 Some(url::Host::Ipv4(ip)) => check_ip_not_private(IpAddr::V4(ip), &redacted)?,
121 Some(url::Host::Ipv6(ip)) => check_ip_not_private(IpAddr::V6(ip), &redacted)?,
122 None => {
123 return Err(SsrfError::InvalidUrl(format!(
124 "URL `{redacted}` must include a host"
125 )));
126 }
127 }
128
129 Ok(parsed)
130}
131
132pub fn validate_base_url(cfg: &OpenApiSpecConfig) -> Result<url::Url, OpenApiError> {
133 validate_https_url(&cfg.label, &cfg.base_url)?;
134 Ok(cfg.base_url.clone())
135}
136
137pub fn validate_spec_url(label: &str, url: &url::Url) -> Result<(), OpenApiError> {
138 validate_https_url(label, url)
139}
140
141fn validate_https_url(label: &str, url: &url::Url) -> Result<(), OpenApiError> {
142 parse_validated_https_url(url.as_str())
143 .map(|_| ())
144 .map_err(|error| OpenApiError::SsrfRejected {
145 label: label.to_string(),
146 reason: error.kind().to_string(),
147 })
148}