Skip to main content

systemprompt_models/net/
client.rs

1//! Connect-time SSRF enforcement for outbound HTTP clients.
2//!
3//! [`validate_outbound_url`](super::validate_outbound_url) can only see what a
4//! URL string says. A hostname is not an address, so parse-time validation
5//! cannot decide whether `metadata.example.com` is a public host or an `A`
6//! record pointing at `169.254.169.254`. This module moves the decision to the
7//! point where the address is actually known.
8//!
9//! [`GuardedResolver`] is installed as the client's DNS resolver, so every
10//! name reqwest resolves — for the initial request and for every redirect hop,
11//! since each hop connects afresh — is filtered through
12//! [`is_blocked_ip`] before a socket is opened. The
13//! redirect policy re-runs the parse-time guard on each hop as well, which
14//! catches a downgrade to a non-HTTPS scheme or a literal blocked address that
15//! never reaches the resolver.
16//!
17//! Literal-IP URLs bypass DNS entirely; those are covered by the parse-time
18//! guard, which every caller runs before handing the URL to the client.
19//!
20//! Copyright (c) systemprompt.io — Business Source License 1.1.
21//! See <https://systemprompt.io> for licensing details.
22
23use 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/// Why a guarded client refused to open a connection.
43#[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/// Bounds applied to every request a guarded client makes.
59///
60/// `trusted_hosts` mirrors the `SYSTEMPROMPT_TRUSTED_HTTP_HOSTS` allowance the
61/// parse-time guard honours: a host named there keeps working even when it
62/// resolves inside a blocked range, which is what lets an operator point the
63/// platform at an internal service on purpose. `allow_loopback` covers
64/// `localhost` for local development; literal loopback addresses never reach
65/// the resolver and are governed by the parse-time guard alone.
66#[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/// DNS resolver that refuses to hand reqwest an address the SSRF block list
137/// covers.
138///
139/// `allowed` holds already-lowercased hostnames exempted from the block list.
140#[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    // Why: with following disabled the 3xx must come back as the response so
193    // the caller can see its status; a custom policy that errors on the first
194    // hop would turn it into a transport failure instead.
195    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        // Why: reqwest pushes the URL being left onto `previous` before asking,
220        // so the first hop already sees one entry; `>` follows exactly
221        // `max_redirects` hops, matching `Policy::limited`.
222        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}