Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:43:04.978Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

pinned_host.rs

288 lines · 11.4 KB · rust
1//! A resolved-and-vetted network destination, pinned to the exact IP addresses
2//! that were checked.
3//!
4//! DNS rebinding / SSRF is a time-of-check-to-time-of-use hazard: code that
5//! resolves a hostname, decides it's safe, and then hands the *hostname* back to
6//! something that resolves it *again* has checked one answer and used another. A
7//! hostname that the sandbox distrusts can exploit that window to point a
8//! "granted" host at loopback, the LAN, or a cloud metadata endpoint.
9//!
10//! [`PinnedHost`] closes that window the way [`sandbox::HostFilesystemLocation`]
11//! does for filesystem paths: construction resolves the name and vets every
12//! address *once*, and the resulting value carries the checked addresses by
13//! value. The type is deliberately opaque — it never hands back the hostname for
14//! re-resolution — so the only way to "use" it is to connect to an address it
15//! already vetted. Re-deriving a destination from the hostname requires reaching
16//! for [`PinnedHost::untrusted_host_display`], whose name flags it as
17//! display-only, so the type system nudges callers away from reintroducing the
18//! TOCTOU.
19//!
20//! One `PinnedHost` describes **one** hostname's resolved addresses. A redirect
21//! chain that visits several hosts produces one `PinnedHost` per hop, each
22//! resolved and vetted independently at the moment its hop runs.
23
24use crate::allowlist::Allowlist;
25use std::collections::HashSet;
26use std::net::{IpAddr, Ipv4Addr, SocketAddr, ToSocketAddrs as _};
27
28/// Why pinning a host failed.
29#[derive(Debug, thiserror::Error)]
30pub enum PinnedHostError {
31    /// DNS resolution itself failed (or the host is malformed).
32    #[error("resolving {host}:{port}: {source}")]
33    Resolve {
34        host: String,
35        port: u16,
36        source: std::io::Error,
37    },
38    /// The host resolved, but to no addresses at all.
39    #[error("{host}:{port} did not resolve to any address")]
40    NoAddresses { host: String, port: u16 },
41    /// Every resolved address was in loopback / private / link-local space, so
42    /// there is nothing safe to connect to (DNS-rebinding protection).
43    #[error(
44        "{host} resolved only to loopback/private/link-local addresses, \
45         which the sandbox never reaches by hostname"
46    )]
47    AllAddressesForbidden { host: String },
48}
49
50/// A hostname whose DNS has been resolved and whose addresses have been vetted
51/// against the forbidden-IP policy, pinned to those exact addresses.
52///
53/// Construct with [`PinnedHost::resolve`] (applies the forbidden-IP filter) or
54/// [`PinnedHost::resolve_allowing_any`] (skips it, for the "allow any host"
55/// grant that means unrestricted egress). Use the pinned addresses via
56/// [`PinnedHost::socket_addrs`]; the hostname is available only via
57/// [`PinnedHost::untrusted_host_display`], which must never be fed back into a
58/// resolver.
59#[derive(Debug, Clone)]
60pub struct PinnedHost {
61    /// The vetted addresses, deduplicated. A set rather than a list because DNS
62    /// order carries no meaning here and duplicate answers (or duplicates across
63    /// A/AAAA records) should collapse — what matters is the set of endpoints we
64    /// concluded are safe to reach.
65    addrs: HashSet<SocketAddr>,
66    /// The requested host, kept **only** for display in errors/UI. Never
67    /// consulted to connect — treat it as untrusted, attacker-influenced text.
68    untrusted_host_for_display: String,
69}
70
71impl PinnedHost {
72    /// Resolve `host:port` and pin the subset of addresses that pass the
73    /// forbidden-IP filter. Fails if resolution fails, yields no addresses, or
74    /// yields only forbidden ones.
75    pub fn resolve(host: &str, port: u16) -> Result<Self, PinnedHostError> {
76        Self::resolve_inner(host, port, true)
77    }
78
79    /// Resolve `host:port` and pin **every** resolved address without applying
80    /// the forbidden-IP filter.
81    ///
82    /// This is for the "allow any host" grant (`allow_all_hosts` /
83    /// [`Allowlist::allows_any`]), which is unrestricted egress by definition —
84    /// including the local network and metadata endpoints. Callers that don't
85    /// hold such a grant must use [`PinnedHost::resolve`].
86    pub fn resolve_allowing_any(host: &str, port: u16) -> Result<Self, PinnedHostError> {
87        Self::resolve_inner(host, port, false)
88    }
89
90    /// Resolve against an [`Allowlist`], choosing the filtered or unfiltered path
91    /// based on whether the allowlist grants arbitrary egress.
92    pub fn resolve_for_allowlist(
93        host: &str,
94        port: u16,
95        allowlist: &Allowlist,
96    ) -> Result<Self, PinnedHostError> {
97        Self::resolve_inner(host, port, !allowlist.allows_any())
98    }
99
100    fn resolve_inner(host: &str, port: u16, vet: bool) -> Result<Self, PinnedHostError> {
101        let resolved =
102            (host, port)
103                .to_socket_addrs()
104                .map_err(|source| PinnedHostError::Resolve {
105                    host: host.to_string(),
106                    port,
107                    source,
108                })?;
109
110        let mut addrs = HashSet::new();
111        let mut saw_any = false;
112        for addr in resolved {
113            saw_any = true;
114            if vet && is_forbidden_ip(addr.ip()) {
115                continue;
116            }
117            addrs.insert(addr);
118        }
119
120        if !saw_any {
121            return Err(PinnedHostError::NoAddresses {
122                host: host.to_string(),
123                port,
124            });
125        }
126        if addrs.is_empty() {
127            return Err(PinnedHostError::AllAddressesForbidden {
128                host: host.to_string(),
129            });
130        }
131
132        Ok(Self {
133            addrs,
134            untrusted_host_for_display: host.to_string(),
135        })
136    }
137
138    /// The vetted addresses to connect to. Connecting to one of these — rather
139    /// than re-resolving the hostname — is what keeps the check and the use
140    /// pinned to the same answer.
141    pub fn socket_addrs(&self) -> impl ExactSizeIterator<Item = SocketAddr> + '_ {
142        self.addrs.iter().copied()
143    }
144
145    /// The requested host, for **display only** (errors, UI). This intentionally
146    /// returns the untrusted, as-requested hostname — never a vetted address. Do
147    /// not feed the result back into a resolver as if it identified this
148    /// destination.
149    pub fn untrusted_host_display(&self) -> &str {
150        &self.untrusted_host_for_display
151    }
152}
153
154/// Whether a resolved address is in loopback / private / link-local space —
155/// destinations a hostname allowlist must never reach. The OS sandbox already
156/// blocks them for direct connections from the sandbox; code running outside the
157/// sandbox (the proxy, the fetch tool) must not reopen them.
158pub fn is_forbidden_ip(ip: IpAddr) -> bool {
159    // Escape hatch for the NixOS sandbox integration tests only: their echo
160    // servers live on the VM's private network, which this filter would
161    // otherwise reject. It is compiled in ONLY under the
162    // `nixos-integration-tests` feature (enabled via `sandbox/nixos-test` when
163    // building `bwrap_test_helper`), so in a real Zed build the env var has no
164    // effect and cannot disable DNS-rebinding/SSRF protection.
165    #[cfg(feature = "nixos-integration-tests")]
166    if std::env::var_os("ZED_SANDBOX_PROXY_ALLOW_LOCAL_IPS").is_some() {
167        return false;
168    }
169    match ip {
170        IpAddr::V4(v4) => is_forbidden_ipv4(v4),
171        IpAddr::V6(v6) => {
172            if let Some(v4) = v6.to_ipv4_mapped() {
173                return is_forbidden_ipv4(v4);
174            }
175            v6.is_loopback()
176                || v6.is_unspecified()
177                // Link-local (fe80::/10) and unique-local (fc00::/7); the
178                // dedicated `is_unicast_link_local` / `is_unique_local`
179                // methods are not yet stable.
180                || (v6.segments()[0] & 0xffc0) == 0xfe80
181                || (v6.segments()[0] & 0xfe00) == 0xfc00
182        }
183    }
184}
185
186fn is_forbidden_ipv4(ip: Ipv4Addr) -> bool {
187    let octets = ip.octets();
188    ip.is_loopback()
189        || ip.is_private()
190        || ip.is_link_local() // includes 169.254.169.254 cloud metadata
191        || ip.is_unspecified()
192        || ip.is_broadcast()
193        // Shared address space (RFC 6598, 100.64.0.0/10): CGNAT, and notably
194        // Tailscale-style overlay networks.
195        || (octets[0] == 100 && (octets[1] & 0xc0) == 64)
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn forbidden_ip_covers_v4_ranges() {
204        for ip in [
205            "127.0.0.1",
206            "10.0.0.1",
207            "192.168.1.1",
208            "172.16.0.1",
209            "169.254.169.254", // cloud metadata
210            "0.0.0.0",
211            "255.255.255.255",
212            "100.64.0.1", // CGNAT / Tailscale
213        ] {
214            assert!(
215                is_forbidden_ip(ip.parse().unwrap()),
216                "expected {ip} to be forbidden"
217            );
218        }
219    }
220
221    #[test]
222    fn forbidden_ip_covers_v6_ranges() {
223        for ip in [
224            "::1",              // loopback
225            "::",               // unspecified
226            "fe80::1",          // link-local
227            "fc00::1",          // unique-local
228            "::ffff:127.0.0.1", // IPv4-mapped loopback
229            "::ffff:10.0.0.1",  // IPv4-mapped private
230        ] {
231            assert!(
232                is_forbidden_ip(ip.parse().unwrap()),
233                "expected {ip} to be forbidden"
234            );
235        }
236    }
237
238    #[test]
239    fn public_ips_are_allowed() {
240        for ip in [
241            "93.184.215.14",
242            "8.8.8.8",
243            "2606:2800:220:1:248:1893:25c8:1946",
244        ] {
245            assert!(
246                !is_forbidden_ip(ip.parse().unwrap()),
247                "expected {ip} to be allowed"
248            );
249        }
250    }
251
252    #[test]
253    fn resolve_pins_public_literal_addresses() {
254        // An IP literal "resolves" to itself, so this exercises the vetting and
255        // pinning without depending on real DNS.
256        let pinned = PinnedHost::resolve("93.184.215.14", 443).expect("public IP should pin");
257        let addrs: HashSet<SocketAddr> = pinned.socket_addrs().collect();
258        assert_eq!(addrs, HashSet::from(["93.184.215.14:443".parse().unwrap()]));
259        assert_eq!(pinned.untrusted_host_display(), "93.184.215.14");
260    }
261
262    #[test]
263    fn resolve_rejects_forbidden_literal_address() {
264        let error = PinnedHost::resolve("127.0.0.1", 80).expect_err("loopback must be rejected");
265        assert!(matches!(
266            error,
267            PinnedHostError::AllAddressesForbidden { .. }
268        ));
269    }
270
271    #[test]
272    fn resolve_allowing_any_keeps_forbidden_addresses() {
273        let pinned =
274            PinnedHost::resolve_allowing_any("127.0.0.1", 80).expect("allow-any keeps loopback");
275        let addrs: HashSet<SocketAddr> = pinned.socket_addrs().collect();
276        assert_eq!(addrs, HashSet::from(["127.0.0.1:80".parse().unwrap()]));
277    }
278
279    #[test]
280    fn resolve_deduplicates_addresses() {
281        // `localhost` typically resolves to both 127.0.0.1 and ::1; under
282        // allow-any both are kept and distinct. This mainly guards that the set
283        // collapses exact duplicates rather than the specific addresses.
284        let pinned = PinnedHost::resolve_allowing_any("127.0.0.1", 80).unwrap();
285        assert_eq!(pinned.socket_addrs().count(), 1);
286    }
287}
288
Served at tenant.openagents/omega Member data and write actions are omitted.