1use serde::{Deserialize, Serialize};
23
24#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
26pub struct NetworkPolicy {
27 #[serde(default, skip_serializing_if = "Vec::is_empty")]
29 pub allowlist: Vec<String>,
30 #[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 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 pub fn ensure_url_allowed(&self, url: &str) -> Result<(), String> {
64 if !self.is_restricted() {
65 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 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
111pub 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
121pub 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 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 let hostport = match authority.rfind('@') {
156 Some(i) => &authority[i + 1..],
157 None => authority,
158 };
159
160 let host = if hostport.starts_with('[') {
161 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 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
180pub 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 !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
204pub 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}