//! A resolved-and-vetted network destination, pinned to the exact IP addresses//! that were checked.//!//! DNS rebinding / SSRF is a time-of-check-to-time-of-use hazard: code that//! resolves a hostname, decides it's safe, and then hands the *hostname* back to//! something that resolves it *again* has checked one answer and used another. A//! hostname that the sandbox distrusts can exploit that window to point a//! "granted" host at loopback, the LAN, or a cloud metadata endpoint.//!//! [`PinnedHost`] closes that window the way [`sandbox::HostFilesystemLocation`]//! does for filesystem paths: construction resolves the name and vets every//! address *once*, and the resulting value carries the checked addresses by//! value. The type is deliberately opaque — it never hands back the hostname for//! re-resolution — so the only way to "use" it is to connect to an address it//! already vetted. Re-deriving a destination from the hostname requires reaching//! for [`PinnedHost::untrusted_host_display`], whose name flags it as//! display-only, so the type system nudges callers away from reintroducing the//! TOCTOU.//!//! One `PinnedHost` describes **one** hostname's resolved addresses. A redirect//! chain that visits several hosts produces one `PinnedHost` per hop, each//! resolved and vetted independently at the moment its hop runs. use crate::allowlist::Allowlist;use std::collections::HashSet;use std::net::{IpAddr, Ipv4Addr, SocketAddr, ToSocketAddrs as _}; /// Why pinning a host failed.#[derive(Debug, thiserror::Error)]pub enum PinnedHostError { /// DNS resolution itself failed (or the host is malformed). #[error("resolving {host}:{port}: {source}")] Resolve { host: String, port: u16, source: std::io::Error, }, /// The host resolved, but to no addresses at all. #[error("{host}:{port} did not resolve to any address")] NoAddresses { host: String, port: u16 }, /// Every resolved address was in loopback / private / link-local space, so /// there is nothing safe to connect to (DNS-rebinding protection). #[error( "{host} resolved only to loopback/private/link-local addresses, \ which the sandbox never reaches by hostname" )] AllAddressesForbidden { host: String },} /// A hostname whose DNS has been resolved and whose addresses have been vetted/// against the forbidden-IP policy, pinned to those exact addresses.////// Construct with [`PinnedHost::resolve`] (applies the forbidden-IP filter) or/// [`PinnedHost::resolve_allowing_any`] (skips it, for the "allow any host"/// grant that means unrestricted egress). Use the pinned addresses via/// [`PinnedHost::socket_addrs`]; the hostname is available only via/// [`PinnedHost::untrusted_host_display`], which must never be fed back into a/// resolver.#[derive(Debug, Clone)]pub struct PinnedHost { /// The vetted addresses, deduplicated. A set rather than a list because DNS /// order carries no meaning here and duplicate answers (or duplicates across /// A/AAAA records) should collapse — what matters is the set of endpoints we /// concluded are safe to reach. addrs: HashSet<SocketAddr>, /// The requested host, kept **only** for display in errors/UI. Never /// consulted to connect — treat it as untrusted, attacker-influenced text. untrusted_host_for_display: String,} impl PinnedHost { /// Resolve `host:port` and pin the subset of addresses that pass the /// forbidden-IP filter. Fails if resolution fails, yields no addresses, or /// yields only forbidden ones. pub fn resolve(host: &str, port: u16) -> Result<Self, PinnedHostError> { Self::resolve_inner(host, port, true) } /// Resolve `host:port` and pin **every** resolved address without applying /// the forbidden-IP filter. /// /// This is for the "allow any host" grant (`allow_all_hosts` / /// [`Allowlist::allows_any`]), which is unrestricted egress by definition — /// including the local network and metadata endpoints. Callers that don't /// hold such a grant must use [`PinnedHost::resolve`]. pub fn resolve_allowing_any(host: &str, port: u16) -> Result<Self, PinnedHostError> { Self::resolve_inner(host, port, false) } /// Resolve against an [`Allowlist`], choosing the filtered or unfiltered path /// based on whether the allowlist grants arbitrary egress. pub fn resolve_for_allowlist( host: &str, port: u16, allowlist: &Allowlist, ) -> Result<Self, PinnedHostError> { Self::resolve_inner(host, port, !allowlist.allows_any()) } fn resolve_inner(host: &str, port: u16, vet: bool) -> Result<Self, PinnedHostError> { let resolved = (host, port) .to_socket_addrs() .map_err(|source| PinnedHostError::Resolve { host: host.to_string(), port, source, })?; let mut addrs = HashSet::new(); let mut saw_any = false; for addr in resolved { saw_any = true; if vet && is_forbidden_ip(addr.ip()) { continue; } addrs.insert(addr); } if !saw_any { return Err(PinnedHostError::NoAddresses { host: host.to_string(), port, }); } if addrs.is_empty() { return Err(PinnedHostError::AllAddressesForbidden { host: host.to_string(), }); } Ok(Self { addrs, untrusted_host_for_display: host.to_string(), }) } /// The vetted addresses to connect to. Connecting to one of these — rather /// than re-resolving the hostname — is what keeps the check and the use /// pinned to the same answer. pub fn socket_addrs(&self) -> impl ExactSizeIterator<Item = SocketAddr> + '_ { self.addrs.iter().copied() } /// The requested host, for **display only** (errors, UI). This intentionally /// returns the untrusted, as-requested hostname — never a vetted address. Do /// not feed the result back into a resolver as if it identified this /// destination. pub fn untrusted_host_display(&self) -> &str { &self.untrusted_host_for_display }} /// Whether a resolved address is in loopback / private / link-local space —/// destinations a hostname allowlist must never reach. The OS sandbox already/// blocks them for direct connections from the sandbox; code running outside the/// sandbox (the proxy, the fetch tool) must not reopen them.pub fn is_forbidden_ip(ip: IpAddr) -> bool { // Escape hatch for the NixOS sandbox integration tests only: their echo // servers live on the VM's private network, which this filter would // otherwise reject. It is compiled in ONLY under the // `nixos-integration-tests` feature (enabled via `sandbox/nixos-test` when // building `bwrap_test_helper`), so in a real Zed build the env var has no // effect and cannot disable DNS-rebinding/SSRF protection. #[cfg(feature = "nixos-integration-tests")] if std::env::var_os("ZED_SANDBOX_PROXY_ALLOW_LOCAL_IPS").is_some() { return false; } match ip { IpAddr::V4(v4) => is_forbidden_ipv4(v4), IpAddr::V6(v6) => { if let Some(v4) = v6.to_ipv4_mapped() { return is_forbidden_ipv4(v4); } v6.is_loopback() || v6.is_unspecified() // Link-local (fe80::/10) and unique-local (fc00::/7); the // dedicated `is_unicast_link_local` / `is_unique_local` // methods are not yet stable. || (v6.segments()[0] & 0xffc0) == 0xfe80 || (v6.segments()[0] & 0xfe00) == 0xfc00 } }} fn is_forbidden_ipv4(ip: Ipv4Addr) -> bool { let octets = ip.octets(); ip.is_loopback() || ip.is_private() || ip.is_link_local() // includes 169.254.169.254 cloud metadata || ip.is_unspecified() || ip.is_broadcast() // Shared address space (RFC 6598, 100.64.0.0/10): CGNAT, and notably // Tailscale-style overlay networks. || (octets[0] == 100 && (octets[1] & 0xc0) == 64)} #[cfg(test)]mod tests { use super::*; #[test] fn forbidden_ip_covers_v4_ranges() { for ip in [ "127.0.0.1", "10.0.0.1", "192.168.1.1", "172.16.0.1", "169.254.169.254", // cloud metadata "0.0.0.0", "255.255.255.255", "100.64.0.1", // CGNAT / Tailscale ] { assert!( is_forbidden_ip(ip.parse().unwrap()), "expected {ip} to be forbidden" ); } } #[test] fn forbidden_ip_covers_v6_ranges() { for ip in [ "::1", // loopback "::", // unspecified "fe80::1", // link-local "fc00::1", // unique-local "::ffff:127.0.0.1", // IPv4-mapped loopback "::ffff:10.0.0.1", // IPv4-mapped private ] { assert!( is_forbidden_ip(ip.parse().unwrap()), "expected {ip} to be forbidden" ); } } #[test] fn public_ips_are_allowed() { for ip in [ "93.184.215.14", "8.8.8.8", "2606:2800:220:1:248:1893:25c8:1946", ] { assert!( !is_forbidden_ip(ip.parse().unwrap()), "expected {ip} to be allowed" ); } } #[test] fn resolve_pins_public_literal_addresses() { // An IP literal "resolves" to itself, so this exercises the vetting and // pinning without depending on real DNS. let pinned = PinnedHost::resolve("93.184.215.14", 443).expect("public IP should pin"); let addrs: HashSet<SocketAddr> = pinned.socket_addrs().collect(); assert_eq!(addrs, HashSet::from(["93.184.215.14:443".parse().unwrap()])); assert_eq!(pinned.untrusted_host_display(), "93.184.215.14"); } #[test] fn resolve_rejects_forbidden_literal_address() { let error = PinnedHost::resolve("127.0.0.1", 80).expect_err("loopback must be rejected"); assert!(matches!( error, PinnedHostError::AllAddressesForbidden { .. } )); } #[test] fn resolve_allowing_any_keeps_forbidden_addresses() { let pinned = PinnedHost::resolve_allowing_any("127.0.0.1", 80).expect("allow-any keeps loopback"); let addrs: HashSet<SocketAddr> = pinned.socket_addrs().collect(); assert_eq!(addrs, HashSet::from(["127.0.0.1:80".parse().unwrap()])); } #[test] fn resolve_deduplicates_addresses() { // `localhost` typically resolves to both 127.0.0.1 and ::1; under // allow-any both are kept and distinct. This mainly guards that the set // collapses exact duplicates rather than the specific addresses. let pinned = PinnedHost::resolve_allowing_any("127.0.0.1", 80).unwrap(); assert_eq!(pinned.socket_addrs().count(), 1); }}
//! A resolved-and-vetted network destination, pinned to the exact IP addresses
//! that were checked.
//!
//! DNS rebinding / SSRF is a time-of-check-to-time-of-use hazard: code that
//! resolves a hostname, decides it's safe, and then hands the *hostname* back to
//! something that resolves it *again* has checked one answer and used another. A
//! hostname that the sandbox distrusts can exploit that window to point a
//! "granted" host at loopback, the LAN, or a cloud metadata endpoint.
//!
//! [`PinnedHost`] closes that window the way [`sandbox::HostFilesystemLocation`]
//! does for filesystem paths: construction resolves the name and vets every
//! address *once*, and the resulting value carries the checked addresses by
//! value. The type is deliberately opaque — it never hands back the hostname for
//! re-resolution — so the only way to "use" it is to connect to an address it
//! already vetted. Re-deriving a destination from the hostname requires reaching
//! for [`PinnedHost::untrusted_host_display`], whose name flags it as
//! display-only, so the type system nudges callers away from reintroducing the
//! TOCTOU.
//!
//! One `PinnedHost` describes **one** hostname's resolved addresses. A redirect
//! chain that visits several hosts produces one `PinnedHost` per hop, each
//! resolved and vetted independently at the moment its hop runs.
use crate::allowlist::Allowlist;
use std::collections::HashSet;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, ToSocketAddrs as _};
/// Why pinning a host failed.
#[derive(Debug, thiserror::Error)]
pub enum PinnedHostError {
/// DNS resolution itself failed (or the host is malformed).
#[error("resolving {host}:{port}: {source}")]
Resolve {
host: String,
port: u16,
source: std::io::Error,
},
/// The host resolved, but to no addresses at all.
#[error("{host}:{port} did not resolve to any address")]
NoAddresses { host: String, port: u16 },
/// Every resolved address was in loopback / private / link-local space, so
/// there is nothing safe to connect to (DNS-rebinding protection).
#[error(
"{host} resolved only to loopback/private/link-local addresses, \
which the sandbox never reaches by hostname"
)]
AllAddressesForbidden { host: String },
}
/// A hostname whose DNS has been resolved and whose addresses have been vetted
/// against the forbidden-IP policy, pinned to those exact addresses.
///
/// Construct with [`PinnedHost::resolve`] (applies the forbidden-IP filter) or
/// [`PinnedHost::resolve_allowing_any`] (skips it, for the "allow any host"
/// grant that means unrestricted egress). Use the pinned addresses via
/// [`PinnedHost::socket_addrs`]; the hostname is available only via
/// [`PinnedHost::untrusted_host_display`], which must never be fed back into a
/// resolver.
#[derive(Debug, Clone)]
pub struct PinnedHost {
/// The vetted addresses, deduplicated. A set rather than a list because DNS
/// order carries no meaning here and duplicate answers (or duplicates across
/// A/AAAA records) should collapse — what matters is the set of endpoints we
/// concluded are safe to reach.
addrs: HashSet<SocketAddr>,
/// The requested host, kept **only** for display in errors/UI. Never
/// consulted to connect — treat it as untrusted, attacker-influenced text.
untrusted_host_for_display: String,
}
impl PinnedHost {
/// Resolve `host:port` and pin the subset of addresses that pass the
/// forbidden-IP filter. Fails if resolution fails, yields no addresses, or
/// yields only forbidden ones.
pub fn resolve(host: &str, port: u16) -> Result<Self, PinnedHostError> {
Self::resolve_inner(host, port, true)
}
/// Resolve `host:port` and pin **every** resolved address without applying
/// the forbidden-IP filter.
///
/// This is for the "allow any host" grant (`allow_all_hosts` /
/// [`Allowlist::allows_any`]), which is unrestricted egress by definition —
/// including the local network and metadata endpoints. Callers that don't
/// hold such a grant must use [`PinnedHost::resolve`].
pub fn resolve_allowing_any(host: &str, port: u16) -> Result<Self, PinnedHostError> {
Self::resolve_inner(host, port, false)
}
/// Resolve against an [`Allowlist`], choosing the filtered or unfiltered path
/// based on whether the allowlist grants arbitrary egress.
pub fn resolve_for_allowlist(
host: &str,
port: u16,
allowlist: &Allowlist,
) -> Result<Self, PinnedHostError> {
Self::resolve_inner(host, port, !allowlist.allows_any())
}
fn resolve_inner(host: &str, port: u16, vet: bool) -> Result<Self, PinnedHostError> {
let resolved =
(host, port)
.to_socket_addrs()
.map_err(|source| PinnedHostError::Resolve {
host: host.to_string(),
port,
source,
})?;
let mut addrs = HashSet::new();
let mut saw_any = false;
for addr in resolved {
saw_any = true;
if vet && is_forbidden_ip(addr.ip()) {
continue;
}
addrs.insert(addr);
}
if !saw_any {
return Err(PinnedHostError::NoAddresses {
host: host.to_string(),
port,
});
}
if addrs.is_empty() {
return Err(PinnedHostError::AllAddressesForbidden {
host: host.to_string(),
});
}
Ok(Self {
addrs,
untrusted_host_for_display: host.to_string(),
})
}
/// The vetted addresses to connect to. Connecting to one of these — rather
/// than re-resolving the hostname — is what keeps the check and the use
/// pinned to the same answer.
pub fn socket_addrs(&self) -> impl ExactSizeIterator<Item = SocketAddr> + '_ {
self.addrs.iter().copied()
}
/// The requested host, for **display only** (errors, UI). This intentionally
/// returns the untrusted, as-requested hostname — never a vetted address. Do
/// not feed the result back into a resolver as if it identified this
/// destination.
pub fn untrusted_host_display(&self) -> &str {
&self.untrusted_host_for_display
}
}
/// Whether a resolved address is in loopback / private / link-local space —
/// destinations a hostname allowlist must never reach. The OS sandbox already
/// blocks them for direct connections from the sandbox; code running outside the
/// sandbox (the proxy, the fetch tool) must not reopen them.
pub fn is_forbidden_ip(ip: IpAddr) -> bool {
// Escape hatch for the NixOS sandbox integration tests only: their echo
// servers live on the VM's private network, which this filter would
// otherwise reject. It is compiled in ONLY under the
// `nixos-integration-tests` feature (enabled via `sandbox/nixos-test` when
// building `bwrap_test_helper`), so in a real Zed build the env var has no
// effect and cannot disable DNS-rebinding/SSRF protection.
#[cfg(feature = "nixos-integration-tests")]
if std::env::var_os("ZED_SANDBOX_PROXY_ALLOW_LOCAL_IPS").is_some() {
return false;
}
match ip {
IpAddr::V4(v4) => is_forbidden_ipv4(v4),
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return is_forbidden_ipv4(v4);
}
v6.is_loopback()
|| v6.is_unspecified()
// Link-local (fe80::/10) and unique-local (fc00::/7); the
// dedicated `is_unicast_link_local` / `is_unique_local`
// methods are not yet stable.
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|| (v6.segments()[0] & 0xfe00) == 0xfc00
}
}
}
fn is_forbidden_ipv4(ip: Ipv4Addr) -> bool {
let octets = ip.octets();
ip.is_loopback()
|| ip.is_private()
|| ip.is_link_local() // includes 169.254.169.254 cloud metadata
|| ip.is_unspecified()
|| ip.is_broadcast()
// Shared address space (RFC 6598, 100.64.0.0/10): CGNAT, and notably
// Tailscale-style overlay networks.
|| (octets[0] == 100 && (octets[1] & 0xc0) == 64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn forbidden_ip_covers_v4_ranges() {
for ip in [
"127.0.0.1",
"10.0.0.1",
"192.168.1.1",
"172.16.0.1",
"169.254.169.254", // cloud metadata
"0.0.0.0",
"255.255.255.255",
"100.64.0.1", // CGNAT / Tailscale
] {
assert!(
is_forbidden_ip(ip.parse().unwrap()),
"expected {ip} to be forbidden"
);
}
}
#[test]
fn forbidden_ip_covers_v6_ranges() {
for ip in [
"::1", // loopback
"::", // unspecified
"fe80::1", // link-local
"fc00::1", // unique-local
"::ffff:127.0.0.1", // IPv4-mapped loopback
"::ffff:10.0.0.1", // IPv4-mapped private
] {
assert!(
is_forbidden_ip(ip.parse().unwrap()),
"expected {ip} to be forbidden"
);
}
}
#[test]
fn public_ips_are_allowed() {
for ip in [
"93.184.215.14",
"8.8.8.8",
"2606:2800:220:1:248:1893:25c8:1946",
] {
assert!(
!is_forbidden_ip(ip.parse().unwrap()),
"expected {ip} to be allowed"
);
}
}
#[test]
fn resolve_pins_public_literal_addresses() {
// An IP literal "resolves" to itself, so this exercises the vetting and
// pinning without depending on real DNS.
let pinned = PinnedHost::resolve("93.184.215.14", 443).expect("public IP should pin");
let addrs: HashSet<SocketAddr> = pinned.socket_addrs().collect();
assert_eq!(addrs, HashSet::from(["93.184.215.14:443".parse().unwrap()]));
assert_eq!(pinned.untrusted_host_display(), "93.184.215.14");
}
#[test]
fn resolve_rejects_forbidden_literal_address() {
let error = PinnedHost::resolve("127.0.0.1", 80).expect_err("loopback must be rejected");
assert!(matches!(
error,
PinnedHostError::AllAddressesForbidden { .. }
));
}
#[test]
fn resolve_allowing_any_keeps_forbidden_addresses() {
let pinned =
PinnedHost::resolve_allowing_any("127.0.0.1", 80).expect("allow-any keeps loopback");
let addrs: HashSet<SocketAddr> = pinned.socket_addrs().collect();
assert_eq!(addrs, HashSet::from(["127.0.0.1:80".parse().unwrap()]));
}
#[test]
fn resolve_deduplicates_addresses() {
// `localhost` typically resolves to both 127.0.0.1 and ::1; under
// allow-any both are kept and distinct. This mainly guards that the set
// collapses exact duplicates rather than the specific addresses.
let pinned = PinnedHost::resolve_allowing_any("127.0.0.1", 80).unwrap();
assert_eq!(pinned.socket_addrs().count(), 1);
}
}