Skip to main content

whycodes_core/
network.rs

1//! Outbound network allowlist / denylist for HTTP tools.
2//!
3//! Applied to tools that open arbitrary or fixed remote URLs (`webfetch`,
4//! `websearch`, GitHub REST helpers). Shell network remains binary
5//! (`security.sandbox_network`); domain filtering for shell would need a
6//! userspace proxy and is out of scope here.
7//!
8//! ## Semantics
9//!
10//! - Empty allowlist → all hosts allowed (subject to denylist).
11//! - Non-empty allowlist → host must match at least one pattern.
12//! - Denylist always wins over allowlist.
13//!
14//! ## Pattern syntax
15//!
16//! - `example.com` — apex and any subdomain (`api.example.com`).
17//! - `*.example.com` — subdomains only (not the apex).
18//! - `*` — match any host.
19//!
20//! Matching is case-insensitive. Ports and userinfo are stripped before match.
21
22use serde::{Deserialize, Serialize};
23
24/// Resolved network policy carried on [`crate::ToolContext`].
25#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
26pub struct NetworkPolicy {
27    /// Allowed host patterns. Empty means unrestricted.
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub allowlist: Vec<String>,
30    /// Always-blocked host patterns (wins over allowlist).
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub denylist: Vec<String>,
33}
34
35impl NetworkPolicy {
36    pub fn unrestricted() -> Self {
37        Self::default()
38    }
39
40    pub fn is_restricted(&self) -> bool {
41        !self.allowlist.is_empty() || !self.denylist.is_empty()
42    }
43
44    /// Whether `host` may be contacted under this policy.
45    pub fn is_host_allowed(&self, host: &str) -> bool {
46        let host = normalize_host(host);
47        if host.is_empty() {
48            return false;
49        }
50        if self.denylist.iter().any(|p| host_matches_pattern(&host, p)) {
51            return false;
52        }
53        if self.allowlist.is_empty() {
54            return true;
55        }
56        self.allowlist
57            .iter()
58            .any(|p| host_matches_pattern(&host, p))
59    }
60
61    /// Parse host from `url` and check policy. On block, returns an error
62    /// message suitable for a tool result.
63    pub fn ensure_url_allowed(&self, url: &str) -> Result<(), String> {
64        if !self.is_restricted() {
65            // Still validate scheme when unrestricted? No — leave that to the
66            // HTTP client. Only enforce host policy when lists are set.
67            // Actually denylist empty + allowlist empty → skip parse.
68            return Ok(());
69        }
70        let host = host_from_url(url)?;
71        if self.is_host_allowed(&host) {
72            Ok(())
73        } else {
74            Err(blocked_message(&host, url, self))
75        }
76    }
77
78    /// Like [`ensure_url_allowed`] but always parses the host when restricted
79    /// *or* when we want a consistent check path for tests/tools.
80    pub fn check_url(&self, url: &str) -> Result<(), String> {
81        let host = host_from_url(url)?;
82        if self.is_host_allowed(&host) {
83            Ok(())
84        } else {
85            Err(blocked_message(&host, url, self))
86        }
87    }
88}
89
90fn blocked_message(host: &str, url: &str, policy: &NetworkPolicy) -> String {
91    let mut msg = format!("Network policy blocked host `{host}` for URL: {url}");
92    if !policy.allowlist.is_empty() {
93        msg.push_str(&format!(
94            "\nAllowed patterns: {}",
95            policy.allowlist.join(", ")
96        ));
97    }
98    if !policy.denylist.is_empty() {
99        msg.push_str(&format!(
100            "\nDenied patterns: {}",
101            policy.denylist.join(", ")
102        ));
103    }
104    msg.push_str(
105        "\nConfigure `security.network_allowlist` / `security.network_denylist` in config.toml \
106         (or WHYCODES_NETWORK_ALLOWLIST / WHYCODES_NETWORK_DENYLIST).",
107    );
108    msg
109}
110
111/// Lowercase host, strip trailing dots and IPv6 brackets.
112pub fn normalize_host(host: &str) -> String {
113    let h = host.trim().trim_end_matches('.').to_ascii_lowercase();
114    if h.starts_with('[') && h.ends_with(']') && h.len() >= 2 {
115        h[1..h.len() - 1].to_string()
116    } else {
117        h
118    }
119}
120
121/// Extract hostname from an `http://` or `https://` URL.
122pub fn host_from_url(url: &str) -> Result<String, String> {
123    let url = url.trim();
124    if url.is_empty() {
125        return Err("URL is empty".to_string());
126    }
127
128    let rest = if let Some(r) = url.strip_prefix("https://") {
129        r
130    } else if let Some(r) = url.strip_prefix("http://") {
131        r
132    } else if let Some(scheme_end) = url.find("://") {
133        let scheme = &url[..scheme_end];
134        return Err(format!(
135            "unsupported URL scheme `{scheme}` (only http and https are allowed for network tools)"
136        ));
137    } else {
138        return Err(format!(
139            "URL must start with http:// or https:// (got: {url})"
140        ));
141    };
142
143    if rest.is_empty() {
144        return Err(format!("could not parse host from URL: {url}"));
145    }
146
147    // authority ends at first `/`, `?`, or `#`
148    let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
149    let authority = &rest[..authority_end];
150    if authority.is_empty() {
151        return Err(format!("could not parse host from URL: {url}"));
152    }
153
154    // userinfo@host:port — take after last @
155    let hostport = match authority.rfind('@') {
156        Some(i) => &authority[i + 1..],
157        None => authority,
158    };
159
160    let host = if hostport.starts_with('[') {
161        // [ipv6] or [ipv6]:port
162        match hostport.find(']') {
163            Some(end) => &hostport[1..end],
164            None => {
165                return Err(format!("invalid IPv6 host in URL: {url}"));
166            }
167        }
168    } else {
169        // host or host:port (first colon separates port for IPv4/hostname)
170        hostport.split(':').next().unwrap_or(hostport)
171    };
172
173    let host = normalize_host(host);
174    if host.is_empty() {
175        return Err(format!("could not parse host from URL: {url}"));
176    }
177    Ok(host)
178}
179
180/// Whether `host` matches a single pattern (see module docs).
181pub fn host_matches_pattern(host: &str, pattern: &str) -> bool {
182    let host = normalize_host(host);
183    let pattern = normalize_host(pattern);
184    if host.is_empty() || pattern.is_empty() {
185        return false;
186    }
187    if pattern == "*" {
188        return true;
189    }
190    if let Some(suffix) = pattern.strip_prefix("*.") {
191        // Subdomains only: `a.suffix` matches; `suffix` does not.
192        !suffix.is_empty()
193            && host.len() > suffix.len()
194            && host.ends_with(suffix)
195            && host.as_bytes().get(host.len() - suffix.len() - 1) == Some(&b'.')
196    } else {
197        host == pattern
198            || (host.len() > pattern.len()
199                && host.ends_with(&pattern)
200                && host.as_bytes().get(host.len() - pattern.len() - 1) == Some(&b'.'))
201    }
202}
203
204/// Split a comma- or whitespace-separated env list into patterns.
205pub fn parse_domain_list(raw: &str) -> Vec<String> {
206    raw.split([',', ' ', '\n', '\t'])
207        .map(str::trim)
208        .filter(|s| !s.is_empty())
209        .map(|s| s.to_string())
210        .collect()
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn policy_url_and_pattern_helpers() {
219        let open = NetworkPolicy::unrestricted();
220        assert!(!open.is_restricted());
221        assert!(open.ensure_url_allowed("https://example.com").is_ok());
222        assert!(!open.is_host_allowed(""));
223        assert!(open.is_host_allowed("example.com"));
224
225        let deny = NetworkPolicy {
226            allowlist: vec!["example.com".into()],
227            denylist: vec!["blocked.example.com".into()],
228        };
229        assert!(deny.is_restricted());
230        assert!(deny.ensure_url_allowed("https://api.example.com/x").is_ok());
231        assert!(deny.check_url("https://api.example.com/x").is_ok());
232        let blocked = deny.check_url("https://blocked.example.com/x").unwrap_err();
233        assert!(blocked.contains("Denied patterns"));
234        assert!(blocked.contains("Allowed patterns"));
235        assert!(deny.ensure_url_allowed("https://evil.test").is_err());
236
237        assert_eq!(
238            host_from_url("https://USER:pw@Example.COM.:443/a?q=1#h").unwrap(),
239            "example.com"
240        );
241        assert_eq!(host_from_url("http://[::1]/x").unwrap(), "::1");
242        assert!(host_from_url("").is_err());
243        assert!(host_from_url("ftp://x").is_err());
244        assert!(host_from_url("example.com").is_err());
245        assert!(host_from_url("https://").is_err());
246        assert!(host_from_url("https:///nohost").is_err());
247        assert!(host_from_url("https://[::1").is_err());
248        assert_eq!(normalize_host("[::1]"), "::1");
249        assert!(host_matches_pattern("a.example.com", "*.example.com"));
250        assert!(!host_matches_pattern("example.com", "*.example.com"));
251        assert!(host_matches_pattern("a.example.com", "example.com"));
252        assert!(host_matches_pattern("x", "*"));
253        assert!(!host_matches_pattern("", "example.com"));
254        assert_eq!(
255            parse_domain_list("a.com, b.com\nc.com"),
256            vec!["a.com", "b.com", "c.com"]
257        );
258    }
259}