systemprompt_models/net/
client.rs1use std::net::SocketAddr;
24use std::time::Duration;
25
26use reqwest::dns::{Addrs, Name, Resolve, Resolving};
27use thiserror::Error;
28
29use super::{
30 HTTP_CONNECT_TIMEOUT, is_blocked_ip, trusted_http_hosts_from_env,
31 validate_outbound_url_with_trust,
32};
33
34const LOOPBACK_HOST: &str = "localhost";
35
36type ConnectError = Box<dyn std::error::Error + Send + Sync>;
37
38fn boxed(error: GuardedConnectError) -> ConnectError {
39 Box::new(error)
40}
41
42#[derive(Debug, Error)]
44pub enum GuardedConnectError {
45 #[error("cannot resolve {0}")]
46 Unresolvable(String),
47 #[error("host {host} resolves to blocked address {addr}")]
48 BlockedAddress {
49 host: String,
50 addr: std::net::IpAddr,
51 },
52 #[error("redirect to {url} refused: {reason}")]
53 RedirectRefused { url: String, reason: String },
54 #[error("more than {0} redirects")]
55 TooManyRedirects(usize),
56}
57
58#[derive(Debug, Clone)]
67pub struct GuardedClientConfig {
68 pub trusted_hosts: Vec<String>,
69 pub allow_loopback: bool,
70 pub max_redirects: usize,
71 pub timeout: Option<Duration>,
72 pub connect_timeout: Duration,
73 pub user_agent: Option<String>,
74}
75
76impl Default for GuardedClientConfig {
77 fn default() -> Self {
78 Self {
79 trusted_hosts: trusted_http_hosts_from_env(),
80 allow_loopback: true,
81 max_redirects: DEFAULT_MAX_REDIRECTS,
82 timeout: Some(super::HTTP_DEFAULT_TIMEOUT),
83 connect_timeout: HTTP_CONNECT_TIMEOUT,
84 user_agent: None,
85 }
86 }
87}
88
89pub const DEFAULT_MAX_REDIRECTS: usize = 3;
90
91impl GuardedClientConfig {
92 #[must_use]
93 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
94 self.timeout = Some(timeout);
95 self
96 }
97
98 #[must_use]
99 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
100 self.user_agent = Some(user_agent.into());
101 self
102 }
103
104 #[must_use]
105 pub const fn with_max_redirects(mut self, max_redirects: usize) -> Self {
106 self.max_redirects = max_redirects;
107 self
108 }
109
110 #[must_use]
111 pub fn with_trusted_hosts(mut self, trusted_hosts: Vec<String>) -> Self {
112 self.trusted_hosts = trusted_hosts;
113 self
114 }
115
116 #[must_use]
117 pub const fn deny_loopback(mut self) -> Self {
118 self.allow_loopback = false;
119 self
120 }
121
122 fn allowed_hosts(&self) -> Vec<String> {
123 let mut allowed: Vec<String> = self
124 .trusted_hosts
125 .iter()
126 .map(|h| h.trim().to_ascii_lowercase())
127 .filter(|h| !h.is_empty())
128 .collect();
129 if self.allow_loopback {
130 allowed.push(LOOPBACK_HOST.to_owned());
131 }
132 allowed
133 }
134}
135
136#[derive(Debug, Clone)]
141pub struct GuardedResolver {
142 allowed: Vec<String>,
143}
144
145impl GuardedResolver {
146 #[must_use]
147 pub fn new(allowed: Vec<String>) -> Self {
148 Self {
149 allowed: allowed
150 .into_iter()
151 .map(|h| h.to_ascii_lowercase())
152 .collect(),
153 }
154 }
155}
156
157impl Resolve for GuardedResolver {
158 fn resolve(&self, name: Name) -> Resolving {
159 let host = name.as_str().to_ascii_lowercase();
160 let exempt = self.allowed.iter().any(|h| h == &host);
161 Box::pin(async move {
162 let addrs: Vec<SocketAddr> = tokio::net::lookup_host((host.as_str(), 0))
163 .await
164 .map_err(|e| {
165 tracing::warn!(host = %host, error = %e, "Outbound DNS resolution failed");
166 boxed(GuardedConnectError::Unresolvable(host.clone()))
167 })?
168 .collect();
169 if addrs.is_empty() {
170 return Err(boxed(GuardedConnectError::Unresolvable(host)));
171 }
172 if !exempt && let Some(blocked) = addrs.iter().find(|a| is_blocked_ip(a.ip())) {
173 tracing::warn!(
174 host = %host,
175 addr = %blocked.ip(),
176 "Refused outbound connection to blocked address"
177 );
178 return Err(boxed(GuardedConnectError::BlockedAddress {
179 host,
180 addr: blocked.ip(),
181 }));
182 }
183 let resolved: Addrs = Box::new(addrs.into_iter());
184 Ok(resolved)
185 })
186 }
187}
188
189pub fn guarded_client_builder(config: &GuardedClientConfig) -> reqwest::ClientBuilder {
190 let trusted = config.allowed_hosts();
191 let resolver = GuardedResolver::new(trusted.clone());
192 let policy = if config.max_redirects == 0 {
196 reqwest::redirect::Policy::none()
197 } else {
198 guarded_redirect_policy(trusted, config.max_redirects)
199 };
200
201 let mut builder = reqwest::Client::builder()
202 .dns_resolver(std::sync::Arc::new(resolver))
203 .redirect(policy)
204 .connect_timeout(config.connect_timeout);
205 if let Some(timeout) = config.timeout {
206 builder = builder.timeout(timeout);
207 }
208 if let Some(user_agent) = &config.user_agent {
209 builder = builder.user_agent(user_agent.clone());
210 }
211 builder
212}
213
214fn guarded_redirect_policy(
215 trusted: Vec<String>,
216 max_redirects: usize,
217) -> reqwest::redirect::Policy {
218 reqwest::redirect::Policy::custom(move |attempt| {
219 if attempt.previous().len() > max_redirects {
223 return attempt.error(GuardedConnectError::TooManyRedirects(max_redirects));
224 }
225 match validate_outbound_url_with_trust(attempt.url().as_str(), &trusted) {
226 Ok(_) => attempt.follow(),
227 Err(e) => {
228 let refused = GuardedConnectError::RedirectRefused {
229 url: attempt.url().to_string(),
230 reason: e.to_string(),
231 };
232 tracing::warn!(error = %refused, "Refused outbound redirect");
233 attempt.error(refused)
234 },
235 }
236 })
237}
238
239pub fn guarded_client(config: &GuardedClientConfig) -> reqwest::Result<reqwest::Client> {
240 guarded_client_builder(config).build()
241}