Skip to repository content484 lines · 18.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:32:29.840Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
allowlist.rs
1//! Hostname allowlist for the proxy's policy decisions.
2//!
3//! Patterns are either exact hostnames (`github.com`) or leading-`*.`
4//! subdomain wildcards (`*.example.com` matches `a.example.com` and
5//! `a.b.example.com`, but **not** `example.com` itself — that's the
6//! convention for subdomain wildcards). No middle wildcards, no regex,
7//! no port matching.
8//!
9//! Hostnames are matched case-insensitively (DNS hostnames are case-insensitive
10//! per RFC 1035 §2.3.3) with any trailing dot stripped (so `example.com.` and
11//! `example.com` are equivalent). Internationalized domain names supplied as
12//! UTF-8 are auto-converted to A-label (Punycode) form so callers don't have
13//! to think about it.
14//!
15//! IP literals (IPv4 / IPv6 / `localhost`) are rejected when constructing a
16//! `HostPattern` — hostname-based allowlisting cannot meaningfully apply to
17//! them, and accepting them would be a policy footgun.
18
19use std::fmt;
20use std::net::IpAddr;
21use thiserror::Error;
22
23/// A hostname pattern accepted by the allowlist.
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub enum HostPattern {
26 /// Matches exactly one hostname (case-insensitive, trailing-dot tolerant).
27 Exact(String),
28 /// Matches subdomains of the contained hostname. `*.example.com` matches
29 /// `a.example.com` and `a.b.example.com`, but not `example.com` itself.
30 Subdomain(String),
31}
32
33impl fmt::Display for HostPattern {
34 /// Renders the pattern back to its canonical user-facing form. The output
35 /// round-trips through [`HostPattern::parse`]: `Exact` prints the bare
36 /// hostname, `Subdomain` prints the leading-`*.` wildcard form.
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 match self {
39 HostPattern::Exact(host) => write!(f, "{host}"),
40 HostPattern::Subdomain(parent) => write!(f, "*.{parent}"),
41 }
42 }
43}
44
45/// Errors returned when parsing a host pattern from a user-provided string.
46#[derive(Debug, Error)]
47pub enum HostPatternError {
48 /// The pattern was empty after trimming.
49 #[error("host pattern cannot be empty")]
50 Empty,
51 /// The pattern contained an IP literal (IPv4, IPv6) or a local hostname
52 /// (`localhost`, `*.localhost`). Hostname-based allowlisting cannot
53 /// meaningfully apply to the local machine.
54 #[error(
55 "host pattern '{0}' is an IP literal or local hostname; only remote hostnames are allowed"
56 )]
57 IpLiteral(String),
58 /// The pattern contained a wildcard somewhere other than as a leading
59 /// label. We support `*.foo.com` but not `foo.*.com`, `*.com`, etc.
60 #[error("host pattern '{0}' has a wildcard somewhere other than the leading label")]
61 InvalidWildcard(String),
62 /// The pattern was syntactically invalid (e.g., empty labels, contained
63 /// characters that no IDN-form hostname could contain).
64 #[error("host pattern '{pattern}' is not a valid hostname: {reason}")]
65 Invalid { pattern: String, reason: String },
66}
67
68impl HostPattern {
69 /// Parse a host pattern from a user-provided string.
70 ///
71 /// Accepts ASCII hostnames or UTF-8 IDN forms (auto-converted to Punycode),
72 /// case-insensitively. A leading `*.` indicates a subdomain wildcard.
73 /// Trailing dots are stripped.
74 ///
75 /// Rejects IP literals, middle/trailing wildcards, empty labels, and
76 /// hostnames `idna` declines to convert.
77 pub fn parse(input: &str) -> Result<Self, HostPatternError> {
78 let trimmed = input.trim();
79 if trimmed.is_empty() {
80 return Err(HostPatternError::Empty);
81 }
82
83 let (is_subdomain, rest) = if let Some(rest) = trimmed.strip_prefix("*.") {
84 (true, rest)
85 } else {
86 (false, trimmed)
87 };
88
89 // Reject any further wildcards. `foo.*.com`, `*.foo.*`, etc.
90 if rest.contains('*') {
91 return Err(HostPatternError::InvalidWildcard(input.to_string()));
92 }
93
94 // Strip a single trailing dot — `example.com.` and `example.com` are
95 // the same host. Comment per project convention: hostnames are
96 // case-insensitive (RFC 1035 §2.3.3) and trailing-dot tolerant.
97 let without_trailing_dot = rest.strip_suffix('.').unwrap_or(rest);
98 if without_trailing_dot.is_empty() {
99 return Err(HostPatternError::Empty);
100 }
101
102 // IDN auto-conversion to Punycode (A-labels). For pure-ASCII inputs
103 // this is a no-op; for UTF-8 it produces the `xn--` form that TLS
104 // SNI and HTTP `Host:` headers actually carry over the wire.
105 let ascii =
106 idna::domain_to_ascii(without_trailing_dot).map_err(|e| HostPatternError::Invalid {
107 pattern: input.to_string(),
108 reason: format!("idna conversion failed: {e}"),
109 })?;
110
111 if ascii.is_empty() {
112 return Err(HostPatternError::Empty);
113 }
114
115 // Reject IP literals (v4 and v6) so that hostname-based allowlisting
116 // can't be subverted by listing an IP. IPv6 literals in hostname
117 // contexts arrive bracketed (`[::1]`); we strip the brackets before
118 // checking.
119 let ip_check_input = ascii
120 .strip_prefix('[')
121 .and_then(|s| s.strip_suffix(']'))
122 .unwrap_or(&ascii);
123 if ip_check_input.parse::<IpAddr>().is_ok() {
124 return Err(HostPatternError::IpLiteral(input.to_string()));
125 }
126 // Final sanity: no empty labels (`foo..bar`), no leading/trailing
127 // dots after our strip-trailing-dot step.
128 if ascii.starts_with('.') || ascii.ends_with('.') || ascii.contains("..") {
129 return Err(HostPatternError::Invalid {
130 pattern: input.to_string(),
131 reason: "empty label".to_string(),
132 });
133 }
134
135 // `idna` lowercases ASCII labels for us; lowercasing again here is a
136 // safety net and a comment that case-insensitive matching is the
137 // model.
138 let canonical = ascii.to_ascii_lowercase();
139
140 // `localhost` (and the whole `.localhost` special-use domain, which
141 // resolvers treat as loopback per RFC 6761) isn't an IP literal but
142 // is treated like one for the purposes of network policy: nothing
143 // about a "hostname allowlist" makes sense for loopback names, and
144 // allowing them would punch a hole through the proxy back into the
145 // host machine.
146 if canonical == "localhost" || canonical.ends_with(".localhost") {
147 return Err(HostPatternError::IpLiteral(input.to_string()));
148 }
149
150 Ok(if is_subdomain {
151 HostPattern::Subdomain(canonical)
152 } else {
153 HostPattern::Exact(canonical)
154 })
155 }
156
157 /// Returns true if this pattern matches the given hostname. The hostname
158 /// should already be in ASCII (Punycode) form — that's how it arrives in
159 /// `CONNECT`/`Host:` lines on the wire.
160 pub fn matches(&self, host: &str) -> bool {
161 // Hostnames are case-insensitive (RFC 1035 §2.3.3) and trailing-dot
162 // tolerant.
163 let canonical = host.strip_suffix('.').unwrap_or(host).to_ascii_lowercase();
164 match self {
165 HostPattern::Exact(pattern) => canonical == *pattern,
166 HostPattern::Subdomain(parent) => {
167 // `*.example.com` matches `a.example.com` and `a.b.example.com`
168 // but **not** `example.com` itself. Some tools include the
169 // bare parent in subdomain wildcards; we don't, by design.
170 canonical
171 .strip_suffix(parent.as_str())
172 .map(|prefix| prefix.ends_with('.') && !prefix.is_empty())
173 .unwrap_or(false)
174 }
175 }
176 }
177
178 /// Returns true if granting this pattern subsumes granting `other` — i.e.
179 /// every host `other` would permit is also permitted by `self`.
180 ///
181 /// This is the host-pattern analogue of filesystem subtree containment,
182 /// used to decide whether an already-granted network permission covers a
183 /// newly requested one (so the user isn't re-prompted). Examples:
184 ///
185 /// - `github.com` covers `github.com` (identical).
186 /// - `*.github.com` covers `api.github.com` and `*.api.github.com`.
187 /// - `*.github.com` does **not** cover `github.com` (the bare parent is
188 /// not a subdomain), and a narrower grant never covers a broader one.
189 pub fn covers(&self, other: &HostPattern) -> bool {
190 match (self, other) {
191 (HostPattern::Exact(a), HostPattern::Exact(b)) => a == b,
192 // An exact grant can only cover that one host, never a wildcard.
193 (HostPattern::Exact(_), HostPattern::Subdomain(_)) => false,
194 (HostPattern::Subdomain(parent), HostPattern::Exact(host)) => {
195 is_subdomain_of(host, parent)
196 }
197 (HostPattern::Subdomain(parent), HostPattern::Subdomain(child)) => {
198 child == parent || is_subdomain_of(child, parent)
199 }
200 }
201 }
202}
203
204/// Whether `host` is a strict subdomain of `parent`. Both are expected to be
205/// in canonical form (lowercase, no trailing dot), as produced by
206/// [`HostPattern::parse`]. `is_subdomain_of("a.example.com", "example.com")`
207/// is true; `is_subdomain_of("example.com", "example.com")` is false.
208fn is_subdomain_of(host: &str, parent: &str) -> bool {
209 host.strip_suffix(parent)
210 .and_then(|prefix| prefix.strip_suffix('.'))
211 .is_some_and(|label| !label.is_empty())
212}
213
214/// Set of host patterns. Construct with `Allowlist::from_patterns` or via
215/// `FromIterator<HostPattern>`.
216#[derive(Debug, Clone, Default)]
217pub struct Allowlist {
218 patterns: Vec<HostPattern>,
219 /// When true, every host is allowed regardless of `patterns`. The proxy
220 /// still observes traffic and emits events; it just doesn't deny.
221 allow_any: bool,
222}
223
224impl Allowlist {
225 /// An allowlist that denies everything. Equivalent to `Allowlist::default()`.
226 pub fn empty() -> Self {
227 Self::default()
228 }
229
230 /// An allowlist that allows any host. When used with the proxy, traffic is
231 /// still observed, but no host policy check is applied.
232 pub fn any() -> Self {
233 Self {
234 patterns: Vec::new(),
235 allow_any: true,
236 }
237 }
238
239 /// Build from an iterator of patterns.
240 pub fn from_patterns<I: IntoIterator<Item = HostPattern>>(patterns: I) -> Self {
241 Self {
242 patterns: patterns.into_iter().collect(),
243 allow_any: false,
244 }
245 }
246
247 /// Returns `true` if the host should be allowed through.
248 pub fn allows(&self, host: &str) -> bool {
249 if self.allow_any {
250 return true;
251 }
252 self.patterns.iter().any(|p| p.matches(host))
253 }
254
255 /// Patterns in this allowlist (for diagnostics / system prompt rendering).
256 pub fn patterns(&self) -> &[HostPattern] {
257 &self.patterns
258 }
259
260 /// Whether this allowlist permits any host without a policy check.
261 pub fn allows_any(&self) -> bool {
262 self.allow_any
263 }
264
265 /// Whether this allowlist denies every host — i.e. it grants nothing,
266 /// so no network plumbing is needed at all.
267 pub fn is_deny_all(&self) -> bool {
268 !self.allow_any && self.patterns.is_empty()
269 }
270}
271
272impl FromIterator<HostPattern> for Allowlist {
273 fn from_iter<I: IntoIterator<Item = HostPattern>>(iter: I) -> Self {
274 Self::from_patterns(iter)
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn parse_exact_hostname() {
284 let p = HostPattern::parse("github.com").unwrap();
285 assert_eq!(p, HostPattern::Exact("github.com".into()));
286 }
287
288 #[test]
289 fn parse_is_case_insensitive() {
290 let p = HostPattern::parse("GitHub.COM").unwrap();
291 assert_eq!(p, HostPattern::Exact("github.com".into()));
292 }
293
294 #[test]
295 fn parse_strips_trailing_dot() {
296 let p = HostPattern::parse("example.com.").unwrap();
297 assert_eq!(p, HostPattern::Exact("example.com".into()));
298 }
299
300 #[test]
301 fn parse_subdomain_wildcard() {
302 let p = HostPattern::parse("*.example.com").unwrap();
303 assert_eq!(p, HostPattern::Subdomain("example.com".into()));
304 }
305
306 #[test]
307 fn parse_idn_to_punycode() {
308 let p = HostPattern::parse("münchen.de").unwrap();
309 assert_eq!(p, HostPattern::Exact("xn--mnchen-3ya.de".into()));
310 }
311
312 #[test]
313 fn parse_rejects_empty() {
314 assert!(matches!(
315 HostPattern::parse("").unwrap_err(),
316 HostPatternError::Empty
317 ));
318 assert!(matches!(
319 HostPattern::parse(" ").unwrap_err(),
320 HostPatternError::Empty
321 ));
322 assert!(matches!(
323 HostPattern::parse("*.").unwrap_err(),
324 HostPatternError::Empty
325 ));
326 }
327
328 #[test]
329 fn parse_rejects_ip_literals() {
330 for ip in ["1.2.3.4", "127.0.0.1", "0.0.0.0", "::1", "[::1]", "fe80::1"] {
331 assert!(
332 matches!(
333 HostPattern::parse(ip).unwrap_err(),
334 HostPatternError::IpLiteral(_)
335 ),
336 "expected IpLiteral for {ip}"
337 );
338 }
339 }
340
341 #[test]
342 fn parse_rejects_localhost() {
343 // The whole `.localhost` special-use domain is loopback per RFC 6761,
344 // not just the bare name.
345 for pattern in [
346 "localhost",
347 "LOCALHOST",
348 "localhost.",
349 "foo.localhost",
350 "*.localhost",
351 "a.b.localhost",
352 ] {
353 assert!(
354 matches!(
355 HostPattern::parse(pattern).unwrap_err(),
356 HostPatternError::IpLiteral(_)
357 ),
358 "expected IpLiteral for {pattern}"
359 );
360 }
361 }
362
363 #[test]
364 fn parse_rejects_middle_wildcards() {
365 for pat in ["foo.*.com", "*.foo.*", "*.*", "foo.*"] {
366 assert!(
367 matches!(
368 HostPattern::parse(pat).unwrap_err(),
369 HostPatternError::InvalidWildcard(_)
370 ),
371 "expected InvalidWildcard for {pat}"
372 );
373 }
374 }
375
376 #[test]
377 fn parse_rejects_empty_labels() {
378 let err = HostPattern::parse("foo..bar").unwrap_err();
379 assert!(matches!(err, HostPatternError::Invalid { .. }));
380 }
381
382 #[test]
383 fn matches_exact_is_case_insensitive() {
384 let p = HostPattern::parse("github.com").unwrap();
385 assert!(p.matches("github.com"));
386 assert!(p.matches("GITHUB.COM"));
387 assert!(p.matches("GitHub.com"));
388 assert!(p.matches("github.com.")); // trailing dot
389 assert!(!p.matches("example.com"));
390 assert!(!p.matches("a.github.com")); // exact does not match subdomains
391 }
392
393 #[test]
394 fn matches_subdomain_wildcard() {
395 let p = HostPattern::parse("*.example.com").unwrap();
396 assert!(p.matches("a.example.com"));
397 assert!(p.matches("a.b.example.com"));
398 assert!(p.matches("A.B.EXAMPLE.COM"));
399 // `*.example.com` does NOT match the bare parent.
400 assert!(!p.matches("example.com"));
401 // Sibling not a subdomain.
402 assert!(!p.matches("notexample.com"));
403 assert!(!p.matches("malicious-example.com"));
404 }
405
406 #[test]
407 fn matches_idn_via_punycode() {
408 let p = HostPattern::parse("münchen.de").unwrap();
409 assert!(p.matches("xn--mnchen-3ya.de"));
410 // Hosts arrive on the wire as Punycode, so we don't try to match the
411 // UTF-8 form. Just make sure that path works.
412 }
413
414 #[test]
415 fn allowlist_allows_member() {
416 let list = Allowlist::from_patterns(vec![
417 HostPattern::parse("github.com").unwrap(),
418 HostPattern::parse("*.npmjs.org").unwrap(),
419 ]);
420 assert!(list.allows("github.com"));
421 assert!(list.allows("registry.npmjs.org"));
422 assert!(!list.allows("example.com"));
423 assert!(!list.allows("npmjs.org")); // bare parent doesn't match wildcard
424 }
425
426 #[test]
427 fn empty_allowlist_denies_everything() {
428 let list = Allowlist::empty();
429 assert!(!list.allows("anything.com"));
430 }
431
432 #[test]
433 fn any_allowlist_allows_everything() {
434 let list = Allowlist::any();
435 assert!(list.allows("anything.com"));
436 assert!(list.allows("evil.org"));
437 assert!(list.allows("a.b.c.d.e"));
438 assert!(list.allows_any());
439 }
440
441 #[test]
442 fn display_round_trips_through_parse() {
443 for input in ["github.com", "*.example.com", "xn--mnchen-3ya.de"] {
444 let pattern = HostPattern::parse(input).unwrap();
445 let rendered = pattern.to_string();
446 assert_eq!(HostPattern::parse(&rendered).unwrap(), pattern);
447 }
448 assert_eq!(
449 HostPattern::parse("GitHub.com").unwrap().to_string(),
450 "github.com"
451 );
452 assert_eq!(
453 HostPattern::parse("*.NPMJS.org").unwrap().to_string(),
454 "*.npmjs.org"
455 );
456 }
457
458 #[test]
459 fn covers_exact_only_matches_identical() {
460 let github = HostPattern::parse("github.com").unwrap();
461 assert!(github.covers(&HostPattern::parse("github.com").unwrap()));
462 assert!(!github.covers(&HostPattern::parse("api.github.com").unwrap()));
463 assert!(!github.covers(&HostPattern::parse("*.github.com").unwrap()));
464 assert!(!github.covers(&HostPattern::parse("example.com").unwrap()));
465 }
466
467 #[test]
468 fn covers_subdomain_wildcard_subsumes_descendants() {
469 let wildcard = HostPattern::parse("*.github.com").unwrap();
470 // Covers exact subdomains, at any depth.
471 assert!(wildcard.covers(&HostPattern::parse("api.github.com").unwrap()));
472 assert!(wildcard.covers(&HostPattern::parse("a.b.github.com").unwrap()));
473 // Covers narrower wildcards.
474 assert!(wildcard.covers(&HostPattern::parse("*.api.github.com").unwrap()));
475 assert!(wildcard.covers(&HostPattern::parse("*.github.com").unwrap()));
476 // Does NOT cover the bare parent or unrelated hosts.
477 assert!(!wildcard.covers(&HostPattern::parse("github.com").unwrap()));
478 assert!(!wildcard.covers(&HostPattern::parse("notgithub.com").unwrap()));
479 // A narrower wildcard does not cover a broader one.
480 let narrow = HostPattern::parse("*.api.github.com").unwrap();
481 assert!(!narrow.covers(&HostPattern::parse("*.github.com").unwrap()));
482 }
483}
484