Skip to main content

systemprompt_models/net/
mod.rs

1//! Shared network timeout constants and outbound-URL validation.
2//!
3//! Centralised [`Duration`] values for HTTP client configuration, TCP
4//! readiness probes, and long-poll image generation, so every caller
5//! uses the same tuned timeouts, plus [`validate_outbound_url`] — the
6//! single parse-time SSRF guard applied to every outbound destination.
7//!
8//! Parse-time validation is a pre-filter, not the enforcement point: a
9//! hostname carries no address, so `systemprompt_client::guarded` installs a
10//! DNS resolver that re-applies [`is_blocked_ip`] to every address a name
11//! resolves to, on the initial request and on every redirect hop. Reach for
12//! `systemprompt_client::guarded_client` rather than
13//! `reqwest::Client::builder()` for any destination a caller can influence.
14//!
15//! Copyright (c) systemprompt.io — Business Source License 1.1.
16//! See <https://systemprompt.io> for licensing details.
17
18use std::time::Duration;
19use thiserror::Error;
20
21/// Rejection reason for an operator-configured outbound URL.
22#[derive(Debug, Error)]
23pub enum OutboundUrlError {
24    #[error("invalid url: {0}")]
25    Parse(String),
26    #[error("unsupported url scheme: {0}")]
27    Scheme(String),
28    #[error("http url only permitted for loopback hosts")]
29    NonLoopbackHttp,
30    #[error("host {0} is in a blocked private range")]
31    BlockedHost(String),
32}
33
34pub const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
35
36pub const HTTP_DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
37
38pub const HTTP_HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
39
40pub const HTTP_AUTH_VERIFY_TIMEOUT: Duration = Duration::from_secs(10);
41
42pub const HTTP_SYNC_DEPLOY_TIMEOUT: Duration = Duration::from_secs(60);
43
44pub const HTTP_STREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
45
46pub const HTTP_KEEPALIVE: Duration = Duration::from_secs(60);
47
48pub const HTTP_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
49
50pub const AGENT_MONITOR_TCP_TIMEOUT: Duration = Duration::from_secs(15);
51
52pub const AGENT_READINESS_TCP_TIMEOUT: Duration = Duration::from_secs(2);
53
54pub const IMAGE_GEN_LONG_POLL_TIMEOUT: Duration = Duration::from_secs(300);
55
56pub const IMAGE_GEN_OPENAI_TIMEOUT: Duration = Duration::from_secs(120);
57
58pub const AI_PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
59
60pub const MCP_TOOL_EXECUTION_TIMEOUT: Duration = Duration::from_secs(30);
61
62pub const TRUSTED_HTTP_HOSTS_ENV: &str = "SYSTEMPROMPT_TRUSTED_HTTP_HOSTS";
63
64#[must_use]
65pub fn trusted_http_hosts_from_env() -> Vec<String> {
66    std::env::var(TRUSTED_HTTP_HOSTS_ENV)
67        .ok()
68        .map(|raw| {
69            raw.split(',')
70                .map(|s| s.trim().to_ascii_lowercase())
71                .filter(|s| !s.is_empty())
72                .collect()
73        })
74        .unwrap_or_default()
75}
76
77pub fn trusted_hosts_env_entry(
78    lookup: impl Fn(&str) -> Option<String>,
79) -> Option<(String, String)> {
80    lookup(TRUSTED_HTTP_HOSTS_ENV).map(|trusted| (TRUSTED_HTTP_HOSTS_ENV.to_owned(), trusted))
81}
82
83pub fn validate_outbound_url(url: &str) -> Result<url::Url, OutboundUrlError> {
84    let no_trust: [&str; 0] = [];
85    validate_outbound_url_with_trust(url, &no_trust)
86}
87
88pub fn validate_outbound_url_with_trust(
89    url: &str,
90    trusted_http_hosts: &[impl AsRef<str>],
91) -> Result<url::Url, OutboundUrlError> {
92    let parsed = url::Url::parse(url).map_err(|e| OutboundUrlError::Parse(e.to_string()))?;
93    let host = parsed
94        .host()
95        .ok_or_else(|| OutboundUrlError::Parse("missing host".to_owned()))?;
96
97    let is_loopback_host = match &host {
98        url::Host::Domain(d) => d.eq_ignore_ascii_case("localhost"),
99        url::Host::Ipv4(ip) => ip.is_loopback(),
100        url::Host::Ipv6(ip) => ip.is_loopback(),
101    };
102
103    let host_str = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
104    let is_trusted = !host_str.is_empty()
105        && trusted_http_hosts
106            .iter()
107            .any(|h| h.as_ref().eq_ignore_ascii_case(&host_str));
108
109    match parsed.scheme() {
110        "https" => {},
111        "http" if is_loopback_host || is_trusted => {},
112        "http" => return Err(OutboundUrlError::NonLoopbackHttp),
113        scheme => return Err(OutboundUrlError::Scheme(scheme.to_owned())),
114    }
115
116    if is_loopback_host || is_trusted {
117        return Ok(parsed);
118    }
119
120    let blocked = match host {
121        url::Host::Domain(_) => false,
122        url::Host::Ipv4(ip) => is_blocked_v4(ip),
123        url::Host::Ipv6(ip) => is_blocked_v6(ip),
124    };
125    if blocked {
126        return Err(OutboundUrlError::BlockedHost(
127            parsed.host_str().unwrap_or_default().to_owned(),
128        ));
129    }
130    Ok(parsed)
131}
132
133#[must_use]
134pub fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
135    match ip {
136        std::net::IpAddr::V4(v4) => is_blocked_v4(v4),
137        std::net::IpAddr::V6(v6) => is_blocked_v6(v6),
138    }
139}
140
141// Why: RFC 4291 §2.5.5.2 maps `::ffff:0:0/96` to IPv4 and §2.5.5.1 embeds an
142// IPv4 address in the low 32 bits of `::/96`; RFC 6052 `64:ff9b::/96` is the
143// NAT64 well-known prefix, through which `64:ff9b::a9fe:a9fe` reaches
144// 169.254.169.254. Each embedded IPv4 is judged by the IPv4 table.
145fn is_blocked_v6(ip: std::net::Ipv6Addr) -> bool {
146    if let Some(v4) = ip.to_ipv4_mapped() {
147        return is_blocked_v4(v4);
148    }
149    let segments = ip.segments();
150    if let Some(v4) = embedded_v4(&segments) {
151        return is_blocked_v4(v4);
152    }
153    let is_unique_local = (segments[0] & 0xfe00) == 0xfc00;
154    let is_link_local = (segments[0] & 0xffc0) == 0xfe80;
155    let is_multicast = (segments[0] & 0xff00) == 0xff00;
156    ip.is_loopback() || ip.is_unspecified() || is_unique_local || is_link_local || is_multicast
157}
158
159fn embedded_v4(segments: &[u16; 8]) -> Option<std::net::Ipv4Addr> {
160    let is_nat64 = segments[..6] == [0x64, 0xff9b, 0, 0, 0, 0];
161    let is_ipv4_compatible = segments[..6] == [0, 0, 0, 0, 0, 0];
162    if !(is_nat64 || is_ipv4_compatible) {
163        return None;
164    }
165    let [a, b] = segments[6].to_be_bytes();
166    let [c, d] = segments[7].to_be_bytes();
167    Some(std::net::Ipv4Addr::new(a, b, c, d))
168}
169
170// Why: RFC 6598 reserves `100.64.0.0/10` for shared carrier-grade NAT, not
171// public hosts.
172fn is_cgnat_shared_v4(ip: std::net::Ipv4Addr) -> bool {
173    let [a, b, _, _] = ip.octets();
174    a == 100 && (64..=127).contains(&b)
175}
176
177// Why: `0.0.0.0/8` routes to the local host on Linux, `192.0.0.0/24` (RFC
178// 6890) and `198.18.0.0/15` (RFC 2544) are IETF-reserved, and `224.0.0.0/4` /
179// `240.0.0.0/4` are multicast and reserved — none is a public host.
180const fn is_reserved_v4(ip: std::net::Ipv4Addr) -> bool {
181    let [a, b, c, _] = ip.octets();
182    a == 0 || (a == 192 && b == 0 && c == 0) || (a == 198 && (b == 18 || b == 19)) || a >= 224
183}
184
185fn is_blocked_v4(ip: std::net::Ipv4Addr) -> bool {
186    ip.is_private()
187        || ip.is_loopback()
188        || ip.is_link_local()
189        || ip.is_unspecified()
190        || ip.is_broadcast()
191        || is_cgnat_shared_v4(ip)
192        || is_reserved_v4(ip)
193}