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