Skip to main content

systemprompt_models/
net.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 SSRF guard applied to every operator-configured webhook
7//! destination (agent integrations and the governance authz hook).
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use std::time::Duration;
13use thiserror::Error;
14
15/// Rejection reason for an operator-configured outbound URL.
16#[derive(Debug, Error)]
17pub enum OutboundUrlError {
18    #[error("invalid url: {0}")]
19    Parse(String),
20    #[error("unsupported url scheme: {0}")]
21    Scheme(String),
22    #[error("http url only permitted for loopback hosts")]
23    NonLoopbackHttp,
24    #[error("host {0} is in a blocked private range")]
25    BlockedHost(String),
26}
27
28pub const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
29
30pub const HTTP_DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
31
32pub const HTTP_HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
33
34pub const HTTP_AUTH_VERIFY_TIMEOUT: Duration = Duration::from_secs(10);
35
36pub const HTTP_SYNC_DEPLOY_TIMEOUT: Duration = Duration::from_secs(60);
37
38pub const HTTP_STREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
39
40pub const HTTP_KEEPALIVE: Duration = Duration::from_secs(60);
41
42pub const HTTP_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
43
44pub const AGENT_MONITOR_TCP_TIMEOUT: Duration = Duration::from_secs(15);
45
46pub const AGENT_READINESS_TCP_TIMEOUT: Duration = Duration::from_secs(2);
47
48pub const IMAGE_GEN_LONG_POLL_TIMEOUT: Duration = Duration::from_secs(300);
49
50pub const IMAGE_GEN_OPENAI_TIMEOUT: Duration = Duration::from_secs(120);
51
52pub const AI_PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
53
54pub const MCP_TOOL_EXECUTION_TIMEOUT: Duration = Duration::from_secs(30);
55
56pub const TRUSTED_HTTP_HOSTS_ENV: &str = "SYSTEMPROMPT_TRUSTED_HTTP_HOSTS";
57
58#[must_use]
59pub fn trusted_http_hosts_from_env() -> Vec<String> {
60    std::env::var(TRUSTED_HTTP_HOSTS_ENV)
61        .ok()
62        .map(|raw| {
63            raw.split(',')
64                .map(|s| s.trim().to_ascii_lowercase())
65                .filter(|s| !s.is_empty())
66                .collect()
67        })
68        .unwrap_or_default()
69}
70
71pub fn trusted_hosts_env_entry(
72    lookup: impl Fn(&str) -> Option<String>,
73) -> Option<(String, String)> {
74    lookup(TRUSTED_HTTP_HOSTS_ENV).map(|trusted| (TRUSTED_HTTP_HOSTS_ENV.to_owned(), trusted))
75}
76
77pub fn validate_outbound_url(url: &str) -> Result<url::Url, OutboundUrlError> {
78    let no_trust: [&str; 0] = [];
79    validate_outbound_url_with_trust(url, &no_trust)
80}
81
82pub fn validate_outbound_url_with_trust(
83    url: &str,
84    trusted_http_hosts: &[impl AsRef<str>],
85) -> Result<url::Url, OutboundUrlError> {
86    let parsed = url::Url::parse(url).map_err(|e| OutboundUrlError::Parse(e.to_string()))?;
87    let host = parsed
88        .host()
89        .ok_or_else(|| OutboundUrlError::Parse("missing host".to_owned()))?;
90
91    let is_loopback_host = match &host {
92        url::Host::Domain(d) => d.eq_ignore_ascii_case("localhost"),
93        url::Host::Ipv4(ip) => ip.is_loopback(),
94        url::Host::Ipv6(ip) => ip.is_loopback(),
95    };
96
97    let host_str = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
98    let is_trusted = !host_str.is_empty()
99        && trusted_http_hosts
100            .iter()
101            .any(|h| h.as_ref().eq_ignore_ascii_case(&host_str));
102
103    match parsed.scheme() {
104        "https" => {},
105        "http" if is_loopback_host || is_trusted => {},
106        "http" => return Err(OutboundUrlError::NonLoopbackHttp),
107        scheme => return Err(OutboundUrlError::Scheme(scheme.to_owned())),
108    }
109
110    if is_loopback_host || is_trusted {
111        return Ok(parsed);
112    }
113
114    let blocked = match host {
115        url::Host::Domain(_) => false,
116        url::Host::Ipv4(ip) => is_blocked_v4(ip),
117        url::Host::Ipv6(ip) => is_blocked_v6(ip),
118    };
119    if blocked {
120        return Err(OutboundUrlError::BlockedHost(
121            parsed.host_str().unwrap_or_default().to_owned(),
122        ));
123    }
124    Ok(parsed)
125}
126
127#[must_use]
128pub fn is_blocked_ip(ip: std::net::IpAddr) -> bool {
129    match ip {
130        std::net::IpAddr::V4(v4) => is_blocked_v4(v4),
131        std::net::IpAddr::V6(v6) => is_blocked_v6(v6),
132    }
133}
134
135// Why: RFC 4291 §2.5.5.2 maps `::ffff:0:0/96` to IPv4, including private IPv4
136// addresses.
137fn is_blocked_v6(ip: std::net::Ipv6Addr) -> bool {
138    ip.to_ipv4_mapped().map_or_else(
139        || {
140            let segments = ip.segments();
141            let is_unique_local = (segments[0] & 0xfe00) == 0xfc00;
142            let is_link_local = (segments[0] & 0xffc0) == 0xfe80;
143            ip.is_loopback() || ip.is_unspecified() || is_unique_local || is_link_local
144        },
145        is_blocked_v4,
146    )
147}
148
149// Why: RFC 6598 reserves `100.64.0.0/10` for shared carrier-grade NAT, not
150// public hosts.
151fn is_cgnat_shared_v4(ip: std::net::Ipv4Addr) -> bool {
152    let [a, b, _, _] = ip.octets();
153    a == 100 && (64..=127).contains(&b)
154}
155
156fn is_blocked_v4(ip: std::net::Ipv4Addr) -> bool {
157    ip.is_private()
158        || ip.is_loopback()
159        || ip.is_link_local()
160        || ip.is_unspecified()
161        || ip.is_broadcast()
162        || is_cgnat_shared_v4(ip)
163}