systemprompt_models/net/
mod.rs1use std::time::Duration;
20use thiserror::Error;
21
22#[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
155fn 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
184fn is_cgnat_shared_v4(ip: std::net::Ipv4Addr) -> bool {
187 let [a, b, _, _] = ip.octets();
188 a == 100 && (64..=127).contains(&b)
189}
190
191const 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}