Skip to main content

harness_web/
config.rs

1//! Validated configuration for the `web_fetch` tool's security policy.
2//!
3//! [`FetchConfig`] is an opaque, immutable policy value: its fields are private
4//! validated newtypes, so a constructed value can never hold an invalid state.
5//! Build one with [`FetchConfig::builder`] and [`FetchConfigBuilder::build`],
6//! which validates every field and reports a [`ConfigError`], or take the
7//! built-in safe policy with [`FetchConfig::default`].
8//!
9//! The policy governs what a fetch may do: the URL-policy knobs (`allow_http`,
10//! `allow_ports`, `allow_ip_literals`), the address policy (denied CIDR ranges
11//! and exact host-plus-address exceptions), the size caps (`max_bytes`,
12//! `max_chars`), the redirect cap, the timeouts, and the `User-Agent`. The set
13//! of accepted content types is fixed rather than configured.
14
15use std::net::IpAddr;
16use std::time::Duration;
17
18use ipnet::IpNet;
19
20#[path = "config-validate.rs"]
21mod validate;
22
23pub use validate::ConfigError;
24use validate::{
25    validate_allow_hosts, validate_deny_cidrs, validate_limit, validate_redirects,
26    validate_timeout, validate_user_agent,
27};
28
29/// The default ports a fetch may target: HTTP and HTTPS.
30const DEFAULT_ALLOW_PORTS: [u16; 2] = [80, 443];
31
32/// The default cap on redirect hops a single fetch may follow.
33const DEFAULT_MAX_REDIRECTS: usize = 5;
34
35/// The hard ceiling on the redirect cap accepted by the builder.
36const MAX_REDIRECTS_CEILING: usize = 20;
37
38/// The default time allowed to establish a TCP connection.
39const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
40
41/// The default cap on the total time a single request may take.
42const DEFAULT_TIMEOUT: Duration = Duration::from_secs(20);
43
44/// The default time an idle connection is kept in the pool before it is closed.
45const DEFAULT_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(10);
46
47/// The hard ceiling on the connect timeout accepted by the builder.
48const MAX_CONNECT_TIMEOUT: Duration = Duration::from_secs(60);
49
50/// The hard ceiling on the whole-request timeout accepted by the builder.
51const MAX_TIMEOUT: Duration = Duration::from_secs(300);
52
53/// The hard ceiling on the pool-idle timeout accepted by the builder.
54const MAX_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(600);
55
56/// The default `User-Agent` header sent on every request.
57const DEFAULT_USER_AGENT: &str = "harness-webfetch/0.0";
58
59/// The default cap on a response body's decompressed size, in bytes (8 MiB).
60const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024;
61
62/// The hard ceiling on `max_bytes` accepted by the builder (64 MiB).
63const MAX_BYTES_CEILING: usize = 64 * 1024 * 1024;
64
65/// The default cap on the returned text length, in characters.
66const DEFAULT_MAX_CHARS: usize = 40_000;
67
68/// The hard ceiling on `max_chars` accepted by the builder.
69const MAX_CHARS_CEILING: usize = 10_000_000;
70
71/// A `User-Agent` string validated to be a legal HTTP header value.
72#[derive(Debug, Clone, PartialEq, Eq)]
73struct UserAgent(String);
74
75/// A response-body byte cap, guaranteed in `1..=MAX_BYTES_CEILING`.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77struct MaxBytes(usize);
78
79/// A returned-text character cap, guaranteed in `1..=MAX_CHARS_CEILING`.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81struct MaxChars(usize);
82
83/// A redirect-hop cap, guaranteed in `0..=MAX_REDIRECTS_CEILING`.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85struct MaxRedirects(usize);
86
87/// A [`Duration`] guaranteed greater than `Duration::ZERO`.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89struct PositiveDuration(Duration);
90
91/// An exact host-plus-address exception, with the host canonicalized.
92///
93/// The host is lowercased, trimmed, and stripped of a single trailing dot, so a
94/// case- or trailing-dot variant of the configured host still matches the
95/// resolver's representation. Keyed on both host and address, so a rebinding
96/// answer for another name cannot inherit this exception.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub(crate) struct HostAddressException {
99    /// The canonical (lowercased, dot-stripped) host this exception names.
100    host: String,
101    /// The exact address the exception admits for that host.
102    addr: IpAddr,
103}
104
105impl HostAddressException {
106    /// Returns whether `(host, addr)` matches this exception.
107    ///
108    /// `host` is canonicalized the same way the entry's host was, so the
109    /// comparison is case- and trailing-dot-insensitive.
110    #[must_use]
111    pub(crate) fn matches(&self, host: &str, addr: IpAddr) -> bool {
112        self.addr == addr && self.host == canonical_host(host)
113    }
114}
115
116/// Canonicalizes a DNS host for exact-exception comparison.
117fn canonical_host(host: &str) -> String {
118    host.trim().trim_end_matches('.').to_ascii_lowercase()
119}
120
121/// The security policy for the web fetch tool, `promptforge/web/fetch`.
122///
123/// The policy sets which URLs, ports, and addresses a fetch may reach and how
124/// many redirects it may follow. It also sets the size caps on the response and
125/// the returned text, the timeouts, and the `User-Agent` header.
126///
127/// A `FetchConfig` is always valid and immutable once built. Use
128/// [`FetchConfig::default`] for the built-in safe policy, or start from
129/// [`FetchConfig::builder`] to customize one.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct FetchConfig {
132    /// Whether to permit `http://` URLs; `https://` is always allowed.
133    allow_http: bool,
134    /// The ports a fetch may target (matched against the URL's effective port).
135    allow_ports: Vec<u16>,
136    /// Whether to permit a host given as a bare IP literal.
137    allow_ip_literals: bool,
138    /// Extra CIDR ranges denied on top of the built-in blocked ranges.
139    deny_extra: Vec<IpNet>,
140    /// Exact host-plus-address exceptions allowed even when otherwise blocked.
141    allow_exact: Vec<HostAddressException>,
142    /// The maximum number of redirect hops a single fetch may follow.
143    max_redirects: MaxRedirects,
144    /// The largest response body accepted, counted on decompressed bytes.
145    max_bytes: MaxBytes,
146    /// The ceiling on returned text length, in characters.
147    max_chars: MaxChars,
148    /// The time allowed to establish a TCP connection on any hop.
149    connect_timeout: PositiveDuration,
150    /// The cap on the total time a single request may take.
151    timeout: PositiveDuration,
152    /// How long an idle pooled connection is kept before it is closed.
153    pool_idle_timeout: PositiveDuration,
154    /// The validated `User-Agent` header sent on every request.
155    user_agent: UserAgent,
156}
157
158impl FetchConfig {
159    /// Starts a builder seeded with the built-in default policy.
160    #[must_use]
161    pub fn builder() -> FetchConfigBuilder {
162        FetchConfigBuilder::default()
163    }
164
165    /// Whether plain `http://` URLs are permitted.
166    pub(crate) fn allow_http(&self) -> bool {
167        self.allow_http
168    }
169
170    /// The ports a fetch may target.
171    pub(crate) fn allow_ports(&self) -> &[u16] {
172        &self.allow_ports
173    }
174
175    /// Whether a bare IP-literal host is permitted (syntax only; the address is
176    /// still classified against the address policy).
177    pub(crate) fn allow_ip_literals(&self) -> bool {
178        self.allow_ip_literals
179    }
180
181    /// The extra denied CIDR ranges layered on the built-in table.
182    pub(crate) fn deny_extra(&self) -> &[IpNet] {
183        &self.deny_extra
184    }
185
186    /// The exact host-plus-address exceptions.
187    pub(crate) fn allow_exact(&self) -> &[HostAddressException] {
188        &self.allow_exact
189    }
190
191    /// The redirect-hop cap.
192    pub(crate) fn max_redirects(&self) -> usize {
193        self.max_redirects.0
194    }
195
196    /// The response-body byte cap.
197    pub(crate) fn max_bytes(&self) -> usize {
198        self.max_bytes.0
199    }
200
201    /// The returned-text character ceiling.
202    pub(crate) fn max_chars(&self) -> usize {
203        self.max_chars.0
204    }
205
206    /// The per-hop connect timeout.
207    pub(crate) fn connect_timeout(&self) -> Duration {
208        self.connect_timeout.0
209    }
210
211    /// The whole-request timeout.
212    pub(crate) fn timeout(&self) -> Duration {
213        self.timeout.0
214    }
215
216    /// The idle-connection pool timeout.
217    pub(crate) fn pool_idle_timeout(&self) -> Duration {
218        self.pool_idle_timeout.0
219    }
220
221    /// The validated `User-Agent` string.
222    pub(crate) fn user_agent(&self) -> &str {
223        &self.user_agent.0
224    }
225}
226
227impl Default for FetchConfig {
228    fn default() -> FetchConfig {
229        // The constants below are all in range, so this construction is
230        // infallible; the builder validates any caller-supplied override.
231        FetchConfig {
232            allow_http: false,
233            allow_ports: DEFAULT_ALLOW_PORTS.to_vec(),
234            allow_ip_literals: false,
235            deny_extra: Vec::new(),
236            allow_exact: Vec::new(),
237            max_redirects: MaxRedirects(DEFAULT_MAX_REDIRECTS),
238            max_bytes: MaxBytes(DEFAULT_MAX_BYTES),
239            max_chars: MaxChars(DEFAULT_MAX_CHARS),
240            connect_timeout: PositiveDuration(DEFAULT_CONNECT_TIMEOUT),
241            timeout: PositiveDuration(DEFAULT_TIMEOUT),
242            pool_idle_timeout: PositiveDuration(DEFAULT_POOL_IDLE_TIMEOUT),
243            user_agent: UserAgent(DEFAULT_USER_AGENT.to_string()),
244        }
245    }
246}
247
248/// A builder for a custom [`FetchConfig`].
249///
250/// The builder starts from the built-in default policy. Each setter stores its
251/// value as given and returns the builder, so calls can be chained.
252/// [`FetchConfigBuilder::build`] checks every value at once and reports the
253/// first invalid one as a [`ConfigError`].
254#[derive(Debug, Clone)]
255pub struct FetchConfigBuilder {
256    allow_http: bool,
257    allow_ports: Vec<u16>,
258    allow_ip_literals: bool,
259    deny_cidrs: Vec<String>,
260    allow_hosts: Vec<(String, IpAddr)>,
261    max_redirects: usize,
262    max_bytes: usize,
263    max_chars: usize,
264    connect_timeout: Duration,
265    timeout: Duration,
266    pool_idle_timeout: Duration,
267    user_agent: String,
268}
269
270impl Default for FetchConfigBuilder {
271    fn default() -> FetchConfigBuilder {
272        FetchConfigBuilder {
273            allow_http: false,
274            allow_ports: DEFAULT_ALLOW_PORTS.to_vec(),
275            allow_ip_literals: false,
276            deny_cidrs: Vec::new(),
277            allow_hosts: Vec::new(),
278            max_redirects: DEFAULT_MAX_REDIRECTS,
279            max_bytes: DEFAULT_MAX_BYTES,
280            max_chars: DEFAULT_MAX_CHARS,
281            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
282            timeout: DEFAULT_TIMEOUT,
283            pool_idle_timeout: DEFAULT_POOL_IDLE_TIMEOUT,
284            user_agent: DEFAULT_USER_AGENT.to_string(),
285        }
286    }
287}
288
289impl FetchConfigBuilder {
290    /// Sets whether plain `http://` URLs are permitted.
291    #[must_use]
292    pub fn allow_http(mut self, yes: bool) -> FetchConfigBuilder {
293        self.allow_http = yes;
294        self
295    }
296
297    /// Replaces the set of ports a fetch may target.
298    #[must_use]
299    pub fn allow_ports(mut self, ports: impl IntoIterator<Item = u16>) -> FetchConfigBuilder {
300        self.allow_ports = ports.into_iter().collect();
301        self
302    }
303
304    /// Sets whether a URL may give its host as a bare IP address.
305    ///
306    /// This lifts only the syntax rule. The address itself is still checked
307    /// against the blocked ranges. A loopback, private, link-local, or other
308    /// non-global address stays blocked unless an exact host-and-address
309    /// exception allows it.
310    #[must_use]
311    pub fn allow_ip_literals(mut self, yes: bool) -> FetchConfigBuilder {
312        self.allow_ip_literals = yes;
313        self
314    }
315
316    /// Adds a CIDR range to block, on top of the built-in blocked ranges.
317    ///
318    /// [`build`] parses the text and reports a range that fails to parse.
319    ///
320    /// [`build`]: FetchConfigBuilder::build
321    #[must_use]
322    pub fn deny_cidr(mut self, cidr: impl Into<String>) -> FetchConfigBuilder {
323        self.deny_cidrs.push(cidr.into());
324        self
325    }
326
327    /// Adds an exception that lets one host reach one otherwise-blocked address.
328    ///
329    /// The exception applies only when a fetch to `host` connects to `addr`.
330    /// Another host that resolves to `addr` stays blocked. The host match
331    /// ignores case and a trailing dot. [`build`] checks that `host` is a valid
332    /// domain name or IP address. This is the only supported way to reach an
333    /// otherwise-blocked address.
334    ///
335    /// [`build`]: FetchConfigBuilder::build
336    #[must_use]
337    pub fn allow_host_address(
338        mut self,
339        host: impl Into<String>,
340        addr: IpAddr,
341    ) -> FetchConfigBuilder {
342        self.allow_hosts.push((host.into(), addr));
343        self
344    }
345
346    /// Sets the maximum number of redirect hops a single fetch may follow.
347    #[must_use]
348    pub fn max_redirects(mut self, n: usize) -> FetchConfigBuilder {
349        self.max_redirects = n;
350        self
351    }
352
353    /// Sets the largest response body accepted, in decompressed bytes.
354    #[must_use]
355    pub fn max_bytes(mut self, n: usize) -> FetchConfigBuilder {
356        self.max_bytes = n;
357        self
358    }
359
360    /// Sets the ceiling on returned text length, in characters.
361    #[must_use]
362    pub fn max_chars(mut self, n: usize) -> FetchConfigBuilder {
363        self.max_chars = n;
364        self
365    }
366
367    /// Sets the time allowed to open a TCP connection, on every redirect hop.
368    #[must_use]
369    pub fn connect_timeout(mut self, d: Duration) -> FetchConfigBuilder {
370        self.connect_timeout = d;
371        self
372    }
373
374    /// Sets the cap on the total time a single request may take.
375    #[must_use]
376    pub fn timeout(mut self, d: Duration) -> FetchConfigBuilder {
377        self.timeout = d;
378        self
379    }
380
381    /// Sets how long an idle pooled connection is kept before it is closed.
382    #[must_use]
383    pub fn pool_idle_timeout(mut self, d: Duration) -> FetchConfigBuilder {
384        self.pool_idle_timeout = d;
385        self
386    }
387
388    /// Sets the `User-Agent` header sent on every request.
389    #[must_use]
390    pub fn user_agent(mut self, ua: impl Into<String>) -> FetchConfigBuilder {
391        self.user_agent = ua.into();
392        self
393    }
394
395    /// Checks every value and returns the finished [`FetchConfig`].
396    ///
397    /// # Errors
398    /// Returns [`ConfigError`] for the first invalid value it finds:
399    ///
400    /// - a `User-Agent` that is not a legal HTTP header value;
401    /// - a `max_bytes` of zero or above 64 MiB;
402    /// - a `max_chars` of zero or above 10,000,000;
403    /// - a `max_redirects` above 20;
404    /// - a `connect_timeout` of zero or above 60 seconds;
405    /// - a `timeout` of zero or above 300 seconds;
406    /// - a `pool_idle_timeout` of zero or above 600 seconds;
407    /// - a denied CIDR range that fails to parse;
408    /// - an exception host that is not a valid domain name or IP address.
409    pub fn build(self) -> Result<FetchConfig, ConfigError> {
410        let user_agent = validate_user_agent(self.user_agent)?;
411        let max_bytes = validate_limit("max_bytes", self.max_bytes, MAX_BYTES_CEILING)?;
412        let max_chars = validate_limit("max_chars", self.max_chars, MAX_CHARS_CEILING)?;
413        let max_redirects = validate_redirects(self.max_redirects)?;
414        let connect_timeout =
415            validate_timeout("connect_timeout", self.connect_timeout, MAX_CONNECT_TIMEOUT)?;
416        let timeout = validate_timeout("timeout", self.timeout, MAX_TIMEOUT)?;
417        let pool_idle_timeout = validate_timeout(
418            "pool_idle_timeout",
419            self.pool_idle_timeout,
420            MAX_POOL_IDLE_TIMEOUT,
421        )?;
422        let deny_extra = validate_deny_cidrs(self.deny_cidrs)?;
423        let allow_exact = validate_allow_hosts(self.allow_hosts)?;
424
425        Ok(FetchConfig {
426            allow_http: self.allow_http,
427            allow_ports: self.allow_ports,
428            allow_ip_literals: self.allow_ip_literals,
429            deny_extra,
430            allow_exact,
431            max_redirects,
432            max_bytes: MaxBytes(max_bytes),
433            max_chars: MaxChars(max_chars),
434            connect_timeout: PositiveDuration(connect_timeout),
435            timeout: PositiveDuration(timeout),
436            pool_idle_timeout: PositiveDuration(pool_idle_timeout),
437            user_agent,
438        })
439    }
440}
441
442#[cfg(test)]
443#[path = "config-tests.rs"]
444mod tests;