Skip to repository content288 lines · 11.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:34:10.063Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
pinned_host.rs
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