Skip to main content

soma_mcp_client/security/
ssrf.rs

1use std::net::IpAddr;
2
3use thiserror::Error;
4use url::{Host, Url};
5
6use crate::net;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum OutboundPolicy {
10    StrictExternal,
11    AdminProtectedBackend,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ValidatedEndpoint {
16    pub redacted_url: String,
17    pub host: String,
18    pub policy: OutboundPolicy,
19}
20
21#[derive(Debug, Error, PartialEq, Eq)]
22pub enum SsrfError {
23    #[error("invalid URL")]
24    InvalidUrl,
25    #[error("URL scheme is not allowed")]
26    InvalidScheme,
27    #[error("URL must not include credentials")]
28    UserInfoDenied,
29    #[error("host is denied by outbound policy")]
30    HostDenied,
31    #[error("IP address is denied by outbound policy")]
32    IpDenied,
33    #[error("redirect target is denied by outbound policy")]
34    RedirectDenied,
35}
36
37pub fn validate_url(raw: &str, policy: OutboundPolicy) -> Result<ValidatedEndpoint, SsrfError> {
38    let parsed = Url::parse(raw.trim()).map_err(|_| SsrfError::InvalidUrl)?;
39    validate_parsed_url(&parsed, policy)?;
40    let host = parsed.host_str().ok_or(SsrfError::HostDenied)?.to_owned();
41    Ok(ValidatedEndpoint {
42        redacted_url: crate::security::redact::redact_url(raw),
43        host,
44        policy,
45    })
46}
47
48pub fn validate_redirect(
49    original: &ValidatedEndpoint,
50    redirect: &str,
51) -> Result<ValidatedEndpoint, SsrfError> {
52    validate_url(redirect, original.policy).map_err(|_| SsrfError::RedirectDenied)
53}
54
55pub fn validate_resolved_ip(ip: IpAddr, policy: OutboundPolicy) -> Result<(), SsrfError> {
56    if net::is_denied_loopback_or_wildcard(ip)
57        || net::is_link_local(ip)
58        || net::is_metadata_ip(ip)
59        || policy == OutboundPolicy::StrictExternal && net::is_private_or_cgnat(ip)
60    {
61        return Err(SsrfError::IpDenied);
62    }
63    Ok(())
64}
65
66fn validate_parsed_url(parsed: &Url, policy: OutboundPolicy) -> Result<(), SsrfError> {
67    match parsed.scheme() {
68        "https" => {}
69        "http" if policy == OutboundPolicy::AdminProtectedBackend => {}
70        _ => return Err(SsrfError::InvalidScheme),
71    }
72    if !parsed.username().is_empty() || parsed.password().is_some() {
73        return Err(SsrfError::UserInfoDenied);
74    }
75    let host = parsed.host().ok_or(SsrfError::HostDenied)?;
76    match host {
77        Host::Ipv4(ip) => validate_resolved_ip(IpAddr::V4(ip), policy)?,
78        Host::Ipv6(ip) => validate_resolved_ip(IpAddr::V6(ip), policy)?,
79        Host::Domain(host) if net::host_is_private_tld_surprise(host) => {
80            return Err(SsrfError::HostDenied);
81        }
82        Host::Domain(_) => {}
83    }
84    Ok(())
85}
86
87#[cfg(test)]
88#[path = "ssrf_tests.rs"]
89mod tests;