ferrijs_fetch/
net_guard.rs1use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
22use std::sync::Arc;
23
24use ferrijs_permissions::{Container, Denied, Permissions, is_metadata_ip, is_private_ip};
25
26type BoxErr = Box<dyn std::error::Error + Send + Sync>;
29
30pub trait NetPolicy: Send + Sync + std::fmt::Debug {
34 fn check(&self, host: &str, port: Option<u16>) -> Result<(), Denied>;
38}
39
40impl NetPolicy for Permissions {
41 fn check(&self, host: &str, port: Option<u16>) -> Result<(), Denied> {
42 self.check_net(host, port)
43 }
44}
45
46impl NetPolicy for Container {
47 fn check(&self, host: &str, port: Option<u16>) -> Result<(), Denied> {
48 self.check_net(host, port)
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum GuardError {
55 Invalid(String),
57 Blocked(String),
59 Denied(Denied),
61}
62
63impl std::fmt::Display for GuardError {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 match self {
66 Self::Invalid(m) | Self::Blocked(m) => f.write_str(m),
67 Self::Denied(d) => d.fmt(f),
68 }
69 }
70}
71
72impl std::error::Error for GuardError {}
73
74#[derive(Debug, Clone, Default)]
77pub struct NetGuard {
78 pub policy: Option<Arc<dyn NetPolicy>>,
81 pub block_metadata: bool,
84 pub block_private: bool,
87}
88
89impl NetGuard {
90 #[must_use]
93 pub fn is_active(&self) -> bool {
94 self.policy.is_some() || self.block_metadata || self.block_private
95 }
96
97 #[must_use]
100 pub(crate) fn dns_filter(&self) -> Option<(bool, bool)> {
101 (self.block_metadata || self.block_private).then_some((self.block_metadata, self.block_private))
102 }
103}
104
105fn ip_blocked(ip: IpAddr, block_metadata: bool, block_private: bool) -> bool {
107 (block_metadata && is_metadata_ip(ip)) || (block_private && is_private_ip(ip))
108}
109
110pub fn check_url(url: &reqwest::Url, g: &NetGuard) -> Result<(), GuardError> {
118 let scheme = url.scheme();
119 if scheme != "http" && scheme != "https" {
120 return Err(GuardError::Invalid(format!(
121 "scheme \"{scheme}\" is not permitted by the sandbox network policy"
122 )));
123 }
124 let host = url
125 .host_str()
126 .ok_or_else(|| GuardError::Invalid("request to a URL with no host is not permitted".to_string()))?;
127 if let Ok(ip) = host.parse::<IpAddr>()
128 && ip_blocked(ip, g.block_metadata, g.block_private)
129 {
130 return Err(GuardError::Blocked(format!(
131 "request to blocked address {ip} (sandbox network policy)"
132 )));
133 }
134 if let Some(policy) = &g.policy {
135 policy
136 .check(
137 host.trim_start_matches('[').trim_end_matches(']'),
138 url.port_or_known_default(),
139 )
140 .map_err(GuardError::Denied)?;
141 }
142 Ok(())
143}
144
145pub fn preflight(resolved_url: &str, g: &NetGuard) -> Result<(), GuardError> {
152 match reqwest::Url::parse(resolved_url) {
153 Ok(u) => check_url(&u, g),
154 Err(_) => Err(GuardError::Invalid(format!(
155 "request to invalid/relative URL \"{resolved_url}\" is not permitted by the sandbox network policy"
156 ))),
157 }
158}
159
160pub(crate) struct GuardedResolver {
165 pub block_metadata: bool,
166 pub block_private: bool,
167}
168
169impl reqwest::dns::Resolve for GuardedResolver {
170 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
171 let host = name.as_str().to_string();
172 let (bm, bp) = (self.block_metadata, self.block_private);
173 Box::pin(async move {
174 let lookup = tokio::task::spawn_blocking(move || -> std::io::Result<Vec<SocketAddr>> {
175 Ok((host.as_str(), 0u16).to_socket_addrs()?.collect())
176 })
177 .await;
178 let addrs = match lookup {
179 Ok(Ok(a)) => a,
180 Ok(Err(e)) => return Err(Box::new(e) as BoxErr),
181 Err(e) => return Err(Box::new(e) as BoxErr),
182 };
183 let kept: Vec<SocketAddr> = addrs.into_iter().filter(|sa| !ip_blocked(sa.ip(), bm, bp)).collect();
184 if kept.is_empty() {
185 return Err("all resolved addresses blocked by sandbox network policy".into());
186 }
187 Ok(Box::new(kept.into_iter()) as reqwest::dns::Addrs)
188 })
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn only(hosts: &[&str]) -> Arc<dyn NetPolicy> {
197 Arc::new(Permissions::none().allow_net(hosts.iter().copied()).unwrap())
198 }
199
200 #[test]
201 fn check_url_blocks_metadata_by_default_keeps_loopback() {
202 let g = NetGuard {
203 policy: None,
204 block_metadata: true,
205 block_private: false,
206 };
207 assert!(check_url(&reqwest::Url::parse("http://169.254.169.254/").unwrap(), &g).is_err());
208 assert!(check_url(&reqwest::Url::parse("http://127.0.0.1:9/").unwrap(), &g).is_ok());
210 assert!(check_url(&reqwest::Url::parse("file:///etc/passwd").unwrap(), &g).is_err());
212 }
213
214 #[test]
215 fn check_url_enforces_policy_on_any_url() {
216 let g = NetGuard {
217 policy: Some(only(&["allowed.com"])),
218 block_metadata: true,
219 block_private: false,
220 };
221 assert!(check_url(&reqwest::Url::parse("https://allowed.com/x").unwrap(), &g).is_ok());
222 assert!(matches!(
225 check_url(&reqwest::Url::parse("https://evil.com/x").unwrap(), &g),
226 Err(GuardError::Denied(_))
227 ));
228 assert!(check_url(&reqwest::Url::parse("https://allowed.com@evil.com/x").unwrap(), &g).is_err());
230 }
231
232 #[test]
233 fn check_url_applies_the_scheme_default_port() {
234 let g = NetGuard {
235 policy: Some(only(&["allowed.com:443"])),
236 ..Default::default()
237 };
238 assert!(check_url(&reqwest::Url::parse("https://allowed.com/").unwrap(), &g).is_ok());
239 assert!(check_url(&reqwest::Url::parse("http://allowed.com/").unwrap(), &g).is_err());
240 assert!(check_url(&reqwest::Url::parse("https://allowed.com:8443/").unwrap(), &g).is_err());
241 }
242
243 #[test]
244 fn preflight_fails_closed_on_unparsable_url() {
245 let g = NetGuard {
246 policy: Some(only(&["allowed.com"])),
247 block_metadata: true,
248 block_private: false,
249 };
250 assert!(preflight("not a url", &g).is_err());
251 }
252
253 #[test]
254 fn inert_guard_is_not_active() {
255 assert!(!NetGuard::default().is_active());
256 assert!(
257 NetGuard {
258 block_metadata: true,
259 ..Default::default()
260 }
261 .is_active()
262 );
263 }
264}