soma_mcp_client/security/
ssrf.rs1use 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;