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
9use std::time::Duration;
10use thiserror::Error;
11
12/// Rejection reason for an operator-configured outbound URL.
13#[derive(Debug, Error)]
14pub enum OutboundUrlError {
15    #[error("invalid url: {0}")]
16    Parse(String),
17    #[error("unsupported url scheme: {0}")]
18    Scheme(String),
19    #[error("http url only permitted for loopback hosts")]
20    NonLoopbackHttp,
21    #[error("host {0} is in a blocked private range")]
22    BlockedHost(String),
23}
24
25pub const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
26
27pub const HTTP_DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
28
29pub const HTTP_HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
30
31pub const HTTP_AUTH_VERIFY_TIMEOUT: Duration = Duration::from_secs(10);
32
33pub const HTTP_SYNC_DEPLOY_TIMEOUT: Duration = Duration::from_secs(60);
34
35pub const HTTP_STREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
36
37pub const HTTP_KEEPALIVE: Duration = Duration::from_secs(60);
38
39pub const HTTP_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
40
41pub const AGENT_MONITOR_TCP_TIMEOUT: Duration = Duration::from_secs(15);
42
43pub const AGENT_READINESS_TCP_TIMEOUT: Duration = Duration::from_secs(2);
44
45pub const IMAGE_GEN_LONG_POLL_TIMEOUT: Duration = Duration::from_secs(300);
46
47pub const IMAGE_GEN_OPENAI_TIMEOUT: Duration = Duration::from_secs(120);
48
49/// Default per-attempt timeout for a non-streaming AI provider request.
50pub const AI_PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
51
52/// Default maximum gap between two chunks of a streaming AI response.
53pub const AI_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
54
55/// Default timeout for a single MCP tool-call RPC (excludes connection setup).
56pub const MCP_TOOL_EXECUTION_TIMEOUT: Duration = Duration::from_secs(30);
57
58/// Operator-supplied allowlist of non-loopback hostnames reachable over plain
59/// `http`.
60///
61/// Comma-separated, case-insensitive, exact domain match only — no globs, no
62/// IP, no port. The intended use is sealed-network demos (the air-gap scenario)
63/// and behind-the-firewall mock services, where the SSRF guard's default
64/// "loopback-only http" rule would otherwise reject a known-trusted internal
65/// hostname like `mock-inference`. **Default empty** — operator opts in by
66/// naming every host explicitly. Does not loosen the scheme, IP block, or
67/// private-range rules for any host outside the allowlist.
68pub const TRUSTED_HTTP_HOSTS_ENV: &str = "SYSTEMPROMPT_TRUSTED_HTTP_HOSTS";
69
70/// Parse [`TRUSTED_HTTP_HOSTS_ENV`] into a normalised allowlist.
71///
72/// Empty/missing → empty vec. Hosts are trimmed and lower-cased; empty
73/// entries (from `a,,b` typos) are dropped.
74#[must_use]
75pub fn trusted_http_hosts_from_env() -> Vec<String> {
76    std::env::var(TRUSTED_HTTP_HOSTS_ENV)
77        .ok()
78        .map(|raw| {
79            raw.split(',')
80                .map(|s| s.trim().to_ascii_lowercase())
81                .filter(|s| !s.is_empty())
82                .collect()
83        })
84        .unwrap_or_default()
85}
86
87/// Validate an operator-configured outbound webhook destination, returning the
88/// parsed URL on success.
89///
90/// Rejects destinations that point at the local host or known private network
91/// ranges; these would otherwise let a configured webhook exfiltrate
92/// cloud-metadata endpoints (e.g. `169.254.169.254`) or internal services on
93/// the same subnet. The scheme must be `https` for production destinations;
94/// `http` is allowed only for explicit loopback names used during local
95/// development.
96pub fn validate_outbound_url(url: &str) -> Result<url::Url, OutboundUrlError> {
97    let no_trust: [&str; 0] = [];
98    validate_outbound_url_with_trust(url, &no_trust)
99}
100
101/// Same as [`validate_outbound_url`], but accepts an explicit allowlist of
102/// hostnames the operator has marked as reachable over plain `http`.
103///
104/// A host in `trusted_http_hosts` is treated like `localhost` for the scheme
105/// gate (http accepted) and **also bypasses the private-range IP block** for
106/// that hostname's resolution path — the latter matters because in-network
107/// hostnames typically resolve to RFC1918 IPs that the standard guard
108/// rejects. The IP-blocklist is still enforced for every host *not* in the
109/// allowlist.
110///
111/// Matching is exact, case-insensitive, on the URL's parsed host. IPs in the
112/// allowlist are matched literally (allowlist callers should generally use
113/// hostnames, not addresses).
114pub fn validate_outbound_url_with_trust(
115    url: &str,
116    trusted_http_hosts: &[impl AsRef<str>],
117) -> Result<url::Url, OutboundUrlError> {
118    let parsed = url::Url::parse(url).map_err(|e| OutboundUrlError::Parse(e.to_string()))?;
119    let host = parsed
120        .host()
121        .ok_or_else(|| OutboundUrlError::Parse("missing host".to_owned()))?;
122
123    let is_loopback_host = match &host {
124        url::Host::Domain(d) => d.eq_ignore_ascii_case("localhost"),
125        url::Host::Ipv4(ip) => ip.is_loopback(),
126        url::Host::Ipv6(ip) => ip.is_loopback(),
127    };
128
129    let host_str = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
130    let is_trusted = !host_str.is_empty()
131        && trusted_http_hosts
132            .iter()
133            .any(|h| h.as_ref().eq_ignore_ascii_case(&host_str));
134
135    match parsed.scheme() {
136        "https" => {},
137        "http" if is_loopback_host || is_trusted => {},
138        "http" => return Err(OutboundUrlError::NonLoopbackHttp),
139        scheme => return Err(OutboundUrlError::Scheme(scheme.to_owned())),
140    }
141
142    if is_loopback_host || is_trusted {
143        return Ok(parsed);
144    }
145
146    let blocked = match host {
147        url::Host::Domain(_) => false,
148        url::Host::Ipv4(ip) => is_blocked_v4(ip),
149        url::Host::Ipv6(ip) => {
150            // RFC 4291 §2.5.5.2: an ::ffff:0:0/96 address embeds a real IPv4
151            // address; treat it as that IPv4 address for SSRF purposes so a
152            // hand-crafted v4-mapped URL cannot bypass the v4 block list.
153            ip.to_ipv4_mapped().map_or_else(
154                || {
155                    let segments = ip.segments();
156                    let is_unique_local = (segments[0] & 0xfe00) == 0xfc00;
157                    let is_link_local = (segments[0] & 0xffc0) == 0xfe80;
158                    ip.is_loopback() || ip.is_unspecified() || is_unique_local || is_link_local
159                },
160                is_blocked_v4,
161            )
162        },
163    };
164    if blocked {
165        return Err(OutboundUrlError::BlockedHost(
166            parsed.host_str().unwrap_or_default().to_owned(),
167        ));
168    }
169    Ok(parsed)
170}
171
172/// RFC 6598 carrier-grade NAT range `100.64.0.0/10` — operator-routable but
173/// commonly bridges to internal services on cloud-provider managed networks.
174fn is_cgnat_shared_v4(ip: std::net::Ipv4Addr) -> bool {
175    let [a, b, _, _] = ip.octets();
176    a == 100 && (64..=127).contains(&b)
177}
178
179fn is_blocked_v4(ip: std::net::Ipv4Addr) -> bool {
180    ip.is_private()
181        || ip.is_loopback()
182        || ip.is_link_local()
183        || ip.is_unspecified()
184        || ip.is_broadcast()
185        || is_cgnat_shared_v4(ip)
186}