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