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;