Skip to main content

soma_openapi/
ssrf.rs

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                // Intentional Soma hardening beyond the current Lab policy.
44                || 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                // Intentional Soma hardening beyond the current Lab policy.
52                || 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}