Skip to main content

ferrijs_fetch/
net_guard.rs

1//! Sandbox network guard (SSRF defense).
2//!
3//! The permission model decides which hosts a script may name; this
4//! guard makes that decision hold on the wire. It is enforced inside
5//! the send engine so a JS `fetch`, a host's own HTTP client and every
6//! narrowed handler share one implementation:
7//!
8//!  * the effective `net` grant — checked on the initial URL AND on
9//!    every redirect target, so an allowed host cannot 302 a restricted
10//!    caller into an internal address;
11//!  * a DNS filter that drops cloud-metadata / (optionally) private
12//!    resolved addresses, which also defeats DNS rebinding (a public
13//!    hostname that resolves to 169.254.169.254);
14//!  * scheme pinning (http/https only).
15//!
16//! The recommended sandbox posture blocks the cloud-metadata endpoints
17//! for every request (no legitimate script targets them) while
18//! loopback/private stays reachable so local servers keep working
19//! unless the host opts in to blocking them.
20
21use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
22use std::sync::Arc;
23
24use ferrijs_permissions::{Container, Denied, Permissions, is_metadata_ip, is_private_ip};
25
26/// Boxed error for the custom DNS resolver (`reqwest::dns::Resolving`
27/// resolves to `Result<Addrs, BoxError>`).
28type BoxErr = Box<dyn std::error::Error + Send + Sync>;
29
30/// Who decides whether a host may be reached. A bare [`Permissions`]
31/// answers from its `net` grant; a [`Container`] answers from whatever
32/// is in force and runs its hook and audit too.
33pub trait NetPolicy: Send + Sync + std::fmt::Debug {
34  /// # Errors
35  ///
36  /// [`Denied`] naming `host:port`.
37  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/// Why a URL was refused.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum GuardError {
55  /// Not http or https, or no host, or unparsable.
56  Invalid(String),
57  /// A literal or resolved address in a blocked range.
58  Blocked(String),
59  /// The policy refused the host.
60  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/// Per-request network policy. `Default` (no policy, all-false) is
75/// inert — a caller that sets nothing keeps the cached-client fast path.
76#[derive(Debug, Clone, Default)]
77pub struct NetGuard {
78  /// The `net` grant in force. `None` ⇒ any host; `Some` ⇒ its check
79  /// must pass for the initial URL and every redirect hop.
80  pub policy: Option<Arc<dyn NetPolicy>>,
81  /// Block the cloud instance-metadata endpoints (169.254.169.254 /
82  /// `fd00:ec2::254`) at both the URL and the resolved-address layer.
83  pub block_metadata: bool,
84  /// Also block loopback / RFC1918 / link-local / ULA / CGNAT. Off by
85  /// default so local servers on `127.0.0.1` still work; a host opts in.
86  pub block_private: bool,
87}
88
89impl NetGuard {
90  /// Whether this guard changes behaviour at all. When `false` the
91  /// caller uses the unguarded cached-client path (zero overhead).
92  #[must_use]
93  pub fn is_active(&self) -> bool {
94    self.policy.is_some() || self.block_metadata || self.block_private
95  }
96
97  /// The address-family filter this guard needs at the DNS layer, or
98  /// `None` when no address filtering applies (host policy only).
99  #[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
105/// `true` if the address must not be connected to under this guard.
106fn 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
110/// Validate one concrete URL (initial or a redirect target) against the
111/// guard: scheme must be http/https, a literal-IP host is range-checked,
112/// and the host must satisfy the policy.
113///
114/// # Errors
115///
116/// [`GuardError`] with the reason.
117pub 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
145/// Pre-flight the initial (already base-resolved) request URL. A
146/// parse failure under an active guard is a denial (fail closed).
147///
148/// # Errors
149///
150/// [`GuardError`] with the reason.
151pub 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
160/// Custom reqwest DNS resolver that resolves the host normally, then
161/// drops any address the guard forbids. Empty after filtering ⇒ the
162/// connection is refused. This is what defeats DNS rebinding: a public
163/// hostname resolving to a metadata/private address never connects.
164pub(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    // Loopback stays reachable so local servers work.
209    assert!(check_url(&reqwest::Url::parse("http://127.0.0.1:9/").unwrap(), &g).is_ok());
210    // Non-http(s) scheme rejected.
211    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    // This is the per-hop check that closes the redirect SSRF bypass:
223    // the same function the manual redirect loop calls on every hop.
224    assert!(matches!(
225      check_url(&reqwest::Url::parse("https://evil.com/x").unwrap(), &g),
226      Err(GuardError::Denied(_))
227    ));
228    // The userinfo trick does not spoof the host.
229    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}