Skip to main content

harn_vm/egress/
mod.rs

1use crate::value::VmDictExt;
2use std::cell::RefCell;
3use std::collections::BTreeMap;
4#[cfg(test)]
5use std::collections::{HashMap, HashSet};
6use std::net::IpAddr;
7use std::str::FromStr;
8use std::sync::{OnceLock, RwLock};
9
10use ipnet::IpNet;
11use serde_json::json;
12use url::Url;
13
14use crate::event_log::{active_event_log, EventLog, LogEvent, Topic};
15use crate::value::{VmError, VmValue};
16use crate::vm::Vm;
17
18pub mod ssrf;
19
20pub use ssrf::{is_disallowed_ip, GuardedResolver};
21
22pub const HARN_EGRESS_ALLOW_ENV: &str = "HARN_EGRESS_ALLOW";
23pub const HARN_EGRESS_DENY_ENV: &str = "HARN_EGRESS_DENY";
24pub const HARN_EGRESS_DEFAULT_ENV: &str = "HARN_EGRESS_DEFAULT";
25pub const HARN_EGRESS_BLOCK_PRIVATE_ENV: &str = "HARN_EGRESS_BLOCK_PRIVATE";
26pub const HARN_EGRESS_ALLOW_LOOPBACK_ENV: &str = "HARN_EGRESS_ALLOW_LOOPBACK";
27pub const EGRESS_AUDIT_TOPIC: &str = "connectors.egress.audit";
28
29thread_local! {
30    static REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH: RefCell<usize> = const { RefCell::new(0) };
31    static REQUIRE_SSRF_GUARD_DEPTH: RefCell<usize> = const { RefCell::new(0) };
32}
33
34/// Whether the private-address (SSRF) block is engaged for a policy.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
36pub enum SsrfMode {
37    /// No private-address blocking; only host allow/deny rules apply.
38    Off,
39    /// Block any URL whose host is, or resolves to, a private / loopback /
40    /// link-local / metadata / reserved address.
41    #[default]
42    BlockPrivate,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46enum DefaultAction {
47    Allow,
48    Deny,
49}
50
51#[derive(Clone, Debug)]
52struct EgressPolicy {
53    allow: Vec<EgressRule>,
54    deny: Vec<EgressRule>,
55    default: DefaultAction,
56    /// `None` means "caller did not set the axis": the effective mode then
57    /// falls back to [`SsrfMode::BlockPrivate`] when the SSRF guard scope is
58    /// active (e.g. under `harn run`) and [`SsrfMode::Off`] otherwise.
59    block_private: Option<SsrfMode>,
60    allow_loopback: bool,
61}
62
63#[derive(Clone, Debug)]
64struct EgressRule {
65    raw: String,
66    matcher: EgressMatcher,
67    port: Option<u16>,
68}
69
70#[derive(Clone, Debug)]
71enum EgressMatcher {
72    Host(String),
73    Suffix(String),
74    Ip(IpAddr),
75    Cidr(IpNet),
76}
77
78#[derive(Clone, Debug)]
79struct EgressState {
80    #[cfg(not(test))]
81    env_checked: bool,
82    #[cfg(not(test))]
83    policy: Option<ConfiguredPolicy>,
84    #[cfg(test)]
85    test_env_checked: HashSet<std::thread::ThreadId>,
86    #[cfg(test)]
87    test_policies: HashMap<std::thread::ThreadId, ConfiguredPolicy>,
88}
89
90#[derive(Clone, Debug)]
91struct ConfiguredPolicy {
92    source: &'static str,
93    policy: EgressPolicy,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub struct EgressBlocked {
98    pub surface: String,
99    pub url: String,
100    pub host: String,
101    pub port: Option<u16>,
102    pub reason: String,
103}
104
105static EGRESS_STATE: OnceLock<RwLock<EgressState>> = OnceLock::new();
106
107fn state() -> &'static RwLock<EgressState> {
108    EGRESS_STATE.get_or_init(|| {
109        RwLock::new(EgressState {
110            #[cfg(not(test))]
111            env_checked: false,
112            #[cfg(not(test))]
113            policy: None,
114            #[cfg(test)]
115            test_env_checked: HashSet::new(),
116            #[cfg(test)]
117            test_policies: HashMap::new(),
118        })
119    })
120}
121
122pub fn register_egress_builtins(vm: &mut Vm) {
123    vm.register_builtin("egress_policy", |args, _out| {
124        let Some(VmValue::Dict(config)) = args.first() else {
125            return Err(vm_error("egress_policy: requires a config dict"));
126        };
127        let policy = policy_from_config(config)?;
128        install_policy(policy, "stdlib")?;
129        Ok(policy_summary())
130    });
131}
132
133pub async fn enforce_url_allowed(surface: &str, url: &str) -> Result<(), VmError> {
134    // `resolution_required` carries forward a deferred default-deny hostname:
135    // the URL-literal rules did not grant it, but a resolved-IP allow rule
136    // might. The resolution step must then either find a granting address or
137    // block (an unresolvable host has no other path to approval).
138    let resolution_required = match check_url_decision(surface, url)? {
139        SyncCheck::Allowed => false,
140        SyncCheck::DeferToResolution(_) => true,
141        SyncCheck::Blocked(blocked) => {
142            audit_blocked(&blocked).await;
143            return Err(blocked.to_vm_error());
144        }
145    };
146    // Host-name resolution (DNS off the runtime): applies the SSRF private
147    // block AND the NetPolicy IP/CIDR allow & deny rules to the resolved
148    // addresses. The connect-time GuardedResolver is the unbypassable backstop
149    // that re-checks the same predicates on the pinned address; this step gives
150    // callers a clean typed `EgressBlocked` instead of an opaque connect error.
151    if let Some(blocked) = check_url_host_resolution(surface, url, resolution_required).await? {
152        audit_blocked(&blocked).await;
153        return Err(blocked.to_vm_error());
154    }
155    Ok(())
156}
157
158pub fn redirect_url_allowed(surface: &str, previous_url: Option<&str>, url: &str) -> bool {
159    match check_redirect_url(surface, previous_url, url) {
160        Ok(Some(blocked)) => {
161            audit_blocked_background(blocked);
162            false
163        }
164        Ok(None) => true,
165        Err(_) => false,
166    }
167}
168
169pub fn redirect_policy(surface: &'static str, max_redirects: usize) -> reqwest::redirect::Policy {
170    reqwest::redirect::Policy::custom(move |attempt| {
171        if attempt.previous().len() >= max_redirects {
172            attempt.error("too many redirects")
173        } else if redirect_url_allowed(
174            surface,
175            attempt.previous().last().map(|url| url.as_str()),
176            attempt.url().as_str(),
177        ) {
178            attempt.follow()
179        } else {
180            attempt.error("egress policy blocked redirect target")
181        }
182    })
183}
184
185pub fn redact_reqwest_error(error: &reqwest::Error) -> String {
186    redact_diagnostic_text(&error.to_string())
187}
188
189pub fn redact_diagnostic_text(raw: &str) -> String {
190    let policy = crate::redact::current_policy();
191    let urls_redacted = policy.redact_urls_in_text(raw);
192    policy.redact_string(urls_redacted.as_ref()).into_owned()
193}
194
195fn check_redirect_url(
196    surface: &str,
197    previous_url: Option<&str>,
198    raw_url: &str,
199) -> Result<Option<EgressBlocked>, VmError> {
200    if let Some(blocked) = insecure_redirect_downgrade_block(surface, previous_url, raw_url)? {
201        return Ok(Some(blocked));
202    }
203    check_url(surface, raw_url)
204}
205
206fn insecure_redirect_downgrade_block(
207    surface: &str,
208    previous_url: Option<&str>,
209    raw_url: &str,
210) -> Result<Option<EgressBlocked>, VmError> {
211    let Some(previous_url) = previous_url else {
212        return Ok(None);
213    };
214    let previous = Url::parse(previous_url).map_err(|error| {
215        vm_error(format!(
216            "egress: invalid redirect source `{previous_url}`: {error}"
217        ))
218    })?;
219    if previous.scheme() != "https" {
220        return Ok(None);
221    }
222    let target_url = Url::parse(raw_url)
223        .map_err(|error| vm_error(format!("egress: invalid URL `{raw_url}`: {error}")))?;
224    if target_url.scheme() != "http" {
225        return Ok(None);
226    }
227
228    let target = EgressTarget::parse(raw_url)?;
229    if insecure_redirect_loopback_exempt(&target) {
230        return Ok(None);
231    }
232    Ok(Some(blocked(
233        surface,
234        raw_url,
235        &target,
236        "https redirect target downgrades to insecure http".to_string(),
237    )))
238}
239
240fn insecure_redirect_loopback_exempt(target: &EgressTarget) -> bool {
241    let (_, allow_loopback) = current_ssrf_client_settings();
242    allow_loopback && target.is_loopback_host()
243}
244
245pub fn client_error_for_url(surface: &str, url: &str) -> Option<crate::connectors::ClientError> {
246    match check_url(surface, url) {
247        Ok(Some(blocked)) => {
248            audit_blocked_background(blocked.clone());
249            Some(crate::connectors::ClientError::EgressBlocked(blocked))
250        }
251        Ok(None) => None,
252        Err(error) => Some(crate::connectors::ClientError::InvalidArgs(
253            error.to_string(),
254        )),
255    }
256}
257
258pub fn connector_error_for_url(
259    surface: &str,
260    url: &str,
261) -> Option<crate::connectors::ConnectorError> {
262    match check_url(surface, url) {
263        Ok(Some(blocked)) => {
264            audit_blocked_background(blocked.clone());
265            Some(crate::connectors::ConnectorError::Activation(
266                blocked.to_string(),
267            ))
268        }
269        Ok(None) => None,
270        Err(error) => Some(crate::connectors::ConnectorError::Activation(
271            error.to_string(),
272        )),
273    }
274}
275
276pub fn reset_egress_policy_for_host() {
277    let mut guard = state().write().expect("egress policy state poisoned");
278    #[cfg(test)]
279    {
280        let thread_id = std::thread::current().id();
281        guard.test_env_checked.remove(&thread_id);
282        guard.test_policies.remove(&thread_id);
283    }
284    #[cfg(not(test))]
285    {
286        guard.env_checked = false;
287        guard.policy = None;
288    }
289    clear_explicit_egress_policy_requirement_for_host();
290    clear_ssrf_guard_requirement_for_host();
291}
292
293pub(crate) fn clear_explicit_egress_policy_requirement_for_host() {
294    REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH.with(|depth| *depth.borrow_mut() = 0);
295}
296
297#[cfg(test)]
298pub fn reset_egress_policy_for_tests() {
299    reset_egress_policy_for_host();
300}
301
302/// Serializes egress tests that mutate `HARN_EGRESS_*` process-global env or
303/// the global egress policy state. `std::env::set_var` is unsound under
304/// concurrent access, so every env-mutating egress test holds this lock for its
305/// whole body. Recovers from a poisoned mutex so a panicking test cannot wedge
306/// the rest of the suite.
307#[cfg(test)]
308pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
309    use std::sync::{Mutex, OnceLock};
310    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
311    LOCK.get_or_init(|| Mutex::new(()))
312        .lock()
313        .unwrap_or_else(|poisoned| poisoned.into_inner())
314}
315
316/// Install a thread-local egress policy from `(key, value)` config pairs for
317/// tests that need to drive the real HTTP client path without touching
318/// process-global `HARN_EGRESS_*` env (which is unsound under concurrency).
319#[cfg(test)]
320pub(crate) fn install_test_policy(config: &[(&str, VmValue)]) {
321    let map = config
322        .iter()
323        .cloned()
324        .map(|(key, value)| (key.to_string(), value))
325        .collect();
326    let policy = policy_from_config(&map).expect("test egress policy parses");
327    install_policy(policy, "test").expect("test egress policy installs");
328}
329
330/// Scope outbound network to explicit `egress_policy(...)` /
331/// `HARN_EGRESS_*` configuration. Without a configured policy, URL
332/// checks return [`EgressBlocked`] before opening a socket.
333pub fn require_explicit_egress_policy_for_host() -> ExplicitEgressPolicyGuard {
334    REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH.with(|depth| {
335        *depth.borrow_mut() += 1;
336    });
337    ExplicitEgressPolicyGuard
338}
339
340#[derive(Debug)]
341pub struct ExplicitEgressPolicyGuard;
342
343impl Drop for ExplicitEgressPolicyGuard {
344    fn drop(&mut self) {
345        REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH.with(|depth| {
346            let mut depth = depth.borrow_mut();
347            *depth = depth.saturating_sub(1);
348        });
349    }
350}
351
352/// Default-on the SSRF private-address guard for outbound HTTP.
353///
354/// Mirrors [`require_explicit_egress_policy_for_host`]. While in scope, any
355/// URL whose host is, or resolves to, a private/loopback/link-local/metadata
356/// address is blocked by [`check_url`] unless the caller explicitly opts out
357/// via `egress_policy({block_private:"off"})` / `HARN_EGRESS_BLOCK_PRIVATE=off`.
358pub fn require_ssrf_guard_for_host() -> SsrfGuardScope {
359    REQUIRE_SSRF_GUARD_DEPTH.with(|depth| {
360        *depth.borrow_mut() += 1;
361    });
362    SsrfGuardScope
363}
364
365#[derive(Debug)]
366pub struct SsrfGuardScope;
367
368impl Drop for SsrfGuardScope {
369    fn drop(&mut self) {
370        REQUIRE_SSRF_GUARD_DEPTH.with(|depth| {
371            let mut depth = depth.borrow_mut();
372            *depth = depth.saturating_sub(1);
373        });
374    }
375}
376
377pub(crate) fn clear_ssrf_guard_requirement_for_host() {
378    REQUIRE_SSRF_GUARD_DEPTH.with(|depth| *depth.borrow_mut() = 0);
379}
380
381fn ssrf_guard_scope_active() -> bool {
382    REQUIRE_SSRF_GUARD_DEPTH.with(|depth| *depth.borrow() > 0)
383}
384
385/// The effective SSRF settings the HTTP client builder should apply, taking
386/// the configured policy (if any), env seeding, and the default-on guard scope
387/// into account. Returns `(block_private_active, allow_loopback)`.
388///
389/// Used by `crate::http::client::build_http_client` to decide whether to
390/// install a [`GuardedResolver`] (the connect-time backstop) and to key the
391/// pooled-client cache so clients with different SSRF settings never alias.
392pub fn current_ssrf_client_settings() -> (bool, bool) {
393    // Best-effort: seed env so a process configured purely via env is honored.
394    let _ = ensure_env_seeded();
395    let configured = {
396        let guard = state().read().expect("egress policy state poisoned");
397        #[cfg(test)]
398        {
399            guard
400                .test_policies
401                .get(&std::thread::current().id())
402                .cloned()
403        }
404        #[cfg(not(test))]
405        {
406            guard.policy.clone()
407        }
408    };
409    let (mode, allow_loopback) = effective_ssrf_settings(configured.as_ref().map(|c| &c.policy));
410    (mode == SsrfMode::BlockPrivate, allow_loopback)
411}
412
413/// The NetPolicy IP/CIDR rules that must be enforced against a host's
414/// *resolved* addresses at connect time.
415///
416/// This is the connect-time analogue of the URL-literal rule matching done by
417/// [`check_url`]: it carries only the IP-literal and CIDR rules (host/suffix
418/// rules pin to the URL hostname and cannot be evaluated against an address).
419/// `deny` rules block any resolved address they contain; when `allow_active`
420/// is set the allowlist is "only these", so a resolved address that matches no
421/// `allow` rule is rejected.
422#[derive(Clone, Debug, Default, PartialEq, Eq)]
423pub struct ResolvedIpRules {
424    /// Resolved addresses inside any of these nets are blocked (deny wins).
425    pub deny: Vec<IpNet>,
426    /// When `allow_active`, a resolved address must fall inside one of these
427    /// nets (or be host-allowed at the URL layer) to be permitted.
428    pub allow: Vec<IpNet>,
429    /// True when an allowlist with a default-deny posture is in effect, i.e.
430    /// the resolved address must be positively allowed.
431    pub allow_active: bool,
432}
433
434impl ResolvedIpRules {
435    /// True when no resolved-IP rule would ever change a decision, so callers
436    /// can skip installing the connect-time resolver / re-resolving.
437    pub fn is_empty(&self) -> bool {
438        self.deny.is_empty() && !self.allow_active
439    }
440}
441
442/// Collapse an IP-literal rule to a host-sized net so it can be matched the
443/// same way as a CIDR rule.
444fn ip_to_net(ip: IpAddr) -> IpNet {
445    match ip {
446        IpAddr::V4(v4) => IpNet::from(ipnet::Ipv4Net::new(v4, 32).expect("/32 is valid")),
447        IpAddr::V6(v6) => IpNet::from(ipnet::Ipv6Net::new(v6, 128).expect("/128 is valid")),
448    }
449}
450
451/// Extract the IP/CIDR allow & deny rules from a policy into a
452/// [`ResolvedIpRules`] for the connect-time [`GuardedResolver`]. Host/suffix
453/// rules are intentionally dropped — they pin to the URL hostname, never the
454/// resolved IP.
455///
456/// Only PORT-UNSCOPED rules are carried here: the resolver sees addresses
457/// stripped of the destination port (reqwest substitutes the URL port after
458/// resolution), so it cannot honor a `:port` qualifier. Port-scoped IP/CIDR
459/// rules are enforced by the port-aware pre-check in
460/// [`check_url_host_resolution`] instead; dropping them from the backstop avoids
461/// over-blocking other ports to the same address.
462///
463/// `allow_active` reflects a default-deny posture, but it is informational for
464/// the resolver: the backstop enforces only the deny side (a security
465/// boundary), since the allow grant depends on hostname context the resolver
466/// does not have.
467fn resolved_ip_rules_for(policy: &EgressPolicy) -> ResolvedIpRules {
468    let net_of = |rule: &EgressRule| match &rule.matcher {
469        EgressMatcher::Cidr(net) => Some(*net),
470        EgressMatcher::Ip(ip) => Some(ip_to_net(*ip)),
471        _ => None,
472    };
473    let deny: Vec<IpNet> = policy
474        .deny
475        .iter()
476        .filter(|rule| rule.port.is_none())
477        .filter_map(net_of)
478        .collect();
479    let allow: Vec<IpNet> = policy
480        .allow
481        .iter()
482        .filter(|rule| rule.port.is_none())
483        .filter_map(net_of)
484        .collect();
485    ResolvedIpRules {
486        deny,
487        allow,
488        allow_active: policy.default == DefaultAction::Deny,
489    }
490}
491
492/// The NetPolicy IP/CIDR rules the HTTP client builder should enforce at
493/// connect time against resolved addresses, reflecting the configured policy
494/// (if any) plus env seeding.
495///
496/// Used by `crate::http::client::build_http_client` so the connect-time
497/// [`GuardedResolver`] pins the connection to an address that has been checked
498/// against the operator's CIDR/IP allow & deny rules — closing the gap where a
499/// hostname resolving into a denied CIDR (or out of an allowed CIDR) was only
500/// caught for URL-literal IPs.
501pub fn current_resolved_ip_rules() -> ResolvedIpRules {
502    let _ = ensure_env_seeded();
503    let configured = {
504        let guard = state().read().expect("egress policy state poisoned");
505        #[cfg(test)]
506        {
507            guard
508                .test_policies
509                .get(&std::thread::current().id())
510                .cloned()
511        }
512        #[cfg(not(test))]
513        {
514            guard.policy.clone()
515        }
516    };
517    configured
518        .as_ref()
519        .map(|c| resolved_ip_rules_for(&c.policy))
520        .unwrap_or_default()
521}
522
523/// Install the connect-time SSRF [`GuardedResolver`] on an arbitrary reqwest
524/// client builder, using the same effective egress settings
525/// `crate::http::client::build_http_client` applies to the `http_*` builtins.
526///
527/// Outbound clients built OUTSIDE the `http_*` path — the shared LLM clients,
528/// connector clients, MCP discovery/OAuth/card/transport clients, the provider
529/// healthcheck and remote catalog fetchers — route through here so a hostname
530/// that resolves (or DNS-rebinds) to a private / loopback / link-local /
531/// metadata (`169.254.169.254`) / denied-CIDR address is unreachable at connect
532/// time. The resolver is the unbypassable backstop: reqwest can only open a TCP
533/// connection to an address this resolver returns.
534///
535/// Like the `http_*` path, the resolver is installed only when the private
536/// block is engaged OR NetPolicy deny rules exist; otherwise the builder is
537/// returned untouched (e.g. `block_private:off`). The effective loopback hatch
538/// is honored, so a caller that has opted into loopback (or a local model whose
539/// URL is a literal `127.0.0.1`, which never hits the resolver) keeps working.
540pub fn install_ssrf_guard(builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
541    let (block_private, allow_loopback) = current_ssrf_client_settings();
542    let rules = current_resolved_ip_rules();
543    if block_private || !rules.deny.is_empty() {
544        builder.dns_resolver(std::sync::Arc::new(GuardedResolver::with_policy(
545            block_private,
546            allow_loopback,
547            &rules,
548        )))
549    } else {
550        builder
551    }
552}
553
554/// Resolve the effective SSRF mode + loopback hatch given the (optional)
555/// configured policy. An unset `block_private` axis defaults to
556/// [`SsrfMode::BlockPrivate`] iff the guard scope is active.
557fn effective_ssrf_settings(configured: Option<&EgressPolicy>) -> (SsrfMode, bool) {
558    let scope_active = ssrf_guard_scope_active();
559    match configured {
560        Some(policy) => {
561            let mode = policy.block_private.unwrap_or(if scope_active {
562                SsrfMode::BlockPrivate
563            } else {
564                SsrfMode::Off
565            });
566            (mode, policy.allow_loopback)
567        }
568        None => {
569            let mode = if scope_active {
570                SsrfMode::BlockPrivate
571            } else {
572                SsrfMode::Off
573            };
574            (mode, false)
575        }
576    }
577}
578
579/// Reason text for an SSRF private-address block. Carries only the host, never
580/// the resolved address or any secret embedded in the URL.
581fn private_block_reason(host: &str) -> String {
582    format!(
583        "host `{host}` is a disallowed address \
584         (private, loopback, link-local, or metadata IP)"
585    )
586}
587
588/// Synchronous private-address block for IP-literal hosts. Host *names* are
589/// handled by the async [`enforce_url_allowed`] path (DNS off the runtime) and
590/// ultimately by the connect-time [`GuardedResolver`]; this only classifies a
591/// literal address so the sync callers (redirects, connectors) still block the
592/// most direct SSRF attempt.
593fn private_block_for_literal(target: &EgressTarget, allow_loopback: bool) -> Option<String> {
594    target.ip.and_then(|ip| {
595        is_disallowed_ip(ip, allow_loopback).then(|| private_block_reason(&target.host))
596    })
597}
598
599/// Resolve a host to its IP addresses using the blocking OS resolver on the
600/// blocking pool. Returns `None` on resolution failure or when no addresses
601/// are produced (deferred to the connect-time guard).
602async fn resolve_host_addrs(host: &str) -> Option<Vec<IpAddr>> {
603    use std::net::ToSocketAddrs;
604    let host = host.to_string();
605    tokio::task::spawn_blocking(move || {
606        (host.as_str(), 0_u16)
607            .to_socket_addrs()
608            .ok()
609            .map(|addrs| addrs.map(|addr| addr.ip()).collect::<Vec<_>>())
610            .filter(|addrs: &Vec<IpAddr>| !addrs.is_empty())
611    })
612    .await
613    .ok()
614    .flatten()
615}
616
617/// Outcome of the synchronous URL-literal egress check.
618enum SyncCheck {
619    /// The URL is permitted by the URL-literal rules. Resolved-IP rules may
620    /// still apply downstream (see `enforce_url_allowed`).
621    Allowed,
622    /// The URL is blocked outright; no later resolution can grant it.
623    Blocked(EgressBlocked),
624    /// A default-deny allowlist matched no host/suffix rule for this hostname,
625    /// but at least one IP/CIDR allow rule exists. The hostname might still be
626    /// granted if it RESOLVES into an allow net, so the decision is deferred to
627    /// [`check_url_host_resolution`]. Sync-only callers treat this as blocked.
628    DeferToResolution(EgressBlocked),
629}
630
631/// Thin wrapper for the synchronous callers (redirects, connectors,
632/// client-error mapping) that cannot resolve hostnames. A `DeferToResolution`
633/// outcome is conservatively treated as blocked: the URL-literal rules did not
634/// grant it and these paths have no resolution step to consult.
635fn check_url(surface: &str, raw_url: &str) -> Result<Option<EgressBlocked>, VmError> {
636    match check_url_decision(surface, raw_url)? {
637        SyncCheck::Allowed => Ok(None),
638        SyncCheck::Blocked(blocked) | SyncCheck::DeferToResolution(blocked) => Ok(Some(blocked)),
639    }
640}
641
642fn check_url_decision(surface: &str, raw_url: &str) -> Result<SyncCheck, VmError> {
643    ensure_env_seeded()?;
644    let configured = {
645        let guard = state().read().expect("egress policy state poisoned");
646        #[cfg(test)]
647        {
648            let thread_id = std::thread::current().id();
649            guard.test_policies.get(&thread_id).cloned()
650        }
651        #[cfg(not(test))]
652        {
653            guard.policy.clone()
654        }
655    };
656    let (ssrf_mode, allow_loopback) =
657        effective_ssrf_settings(configured.as_ref().map(|c| &c.policy));
658    let require_explicit_policy =
659        REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH.with(|depth| *depth.borrow() > 0);
660
661    let Some(configured) = configured else {
662        // No host allow/deny policy is configured. The private-block still
663        // applies in addition when the SSRF guard is active (default-on).
664        if ssrf_mode == SsrfMode::BlockPrivate {
665            let target = EgressTarget::parse(raw_url)?;
666            if let Some(reason) = private_block_for_literal(&target, allow_loopback) {
667                return Ok(SyncCheck::Blocked(blocked(
668                    surface, raw_url, &target, reason,
669                )));
670            }
671        }
672        if require_explicit_policy {
673            let target = EgressTarget::parse(raw_url)?;
674            return Ok(SyncCheck::Blocked(blocked(
675                surface,
676                raw_url,
677                &target,
678                "no egress policy configured".to_string(),
679            )));
680        }
681        return Ok(SyncCheck::Allowed);
682    };
683    let target = EgressTarget::parse(raw_url)?;
684
685    // Deny-private wins: the SSRF block is applied IN ADDITION to and ahead of
686    // the host allow/deny rules so an `allow` entry cannot re-open a private
687    // address. (Literal IPs only here; host names are resolved in the async
688    // `enforce_url_allowed` path and pinned at connect time.)
689    if ssrf_mode == SsrfMode::BlockPrivate {
690        if let Some(reason) = private_block_for_literal(&target, allow_loopback) {
691            return Ok(SyncCheck::Blocked(blocked(
692                surface, raw_url, &target, reason,
693            )));
694        }
695    }
696
697    if let Some(rule) = configured
698        .policy
699        .deny
700        .iter()
701        .find(|rule| rule.matches(&target))
702    {
703        return Ok(SyncCheck::Blocked(blocked(
704            surface,
705            raw_url,
706            &target,
707            format!("matched deny rule `{}`", rule.raw),
708        )));
709    }
710    if configured
711        .policy
712        .allow
713        .iter()
714        .any(|rule| rule.matches(&target))
715    {
716        return Ok(SyncCheck::Allowed);
717    }
718    if configured.policy.default == DefaultAction::Allow {
719        return Ok(SyncCheck::Allowed);
720    }
721
722    // Default-deny and no host/suffix allow matched. For a literal-IP target
723    // this is final. For a HOSTNAME, an IP/CIDR allow rule could still grant it
724    // once resolved (the gap #3174 closes for the allow direction), so defer.
725    let could_allow_on_resolution = target.ip.is_none()
726        && configured
727            .policy
728            .allow
729            .iter()
730            .any(EgressRule::is_ip_matcher);
731    let blocked = blocked(
732        surface,
733        raw_url,
734        &target,
735        "no allow rule matched".to_string(),
736    );
737    if could_allow_on_resolution {
738        Ok(SyncCheck::DeferToResolution(blocked))
739    } else {
740        Ok(SyncCheck::Blocked(blocked))
741    }
742}
743
744/// Async host-name resolution check. Run by [`enforce_url_allowed`] after the
745/// sync [`check_url`] passes (or after `check_url` deferred a hostname whose
746/// only possible allow is a CIDR/IP rule): resolve the host ONCE off the
747/// runtime and evaluate the resolved addresses against
748///
749///   1. the SSRF private-address block (when `block_private` is active), and
750///   2. the NetPolicy IP/CIDR allow & deny rules ([`ResolvedIpRules`]).
751///
752/// This is the typed-error analogue of the connect-time [`GuardedResolver`]:
753/// it gives callers a clean `EgressBlocked` instead of an opaque connect
754/// error. The GuardedResolver remains the unbypassable backstop — it
755/// re-applies the SAME checks to whatever address reqwest actually pins the
756/// connection to, so a DNS rebind between this pre-check and the connection
757/// cannot defeat a deny CIDR or smuggle a connection out of an allow CIDR.
758///
759/// `resolution_required` is set when the sync layer could not grant the
760/// hostname (default-deny with no host/suffix allow match) and the only way it
761/// can be allowed is a resolved-IP allow rule; in that case an unresolvable
762/// host must be blocked rather than deferred, since nothing downstream can
763/// grant it.
764async fn check_url_host_resolution(
765    surface: &str,
766    raw_url: &str,
767    resolution_required: bool,
768) -> Result<Option<EgressBlocked>, VmError> {
769    let configured = {
770        let guard = state().read().expect("egress policy state poisoned");
771        #[cfg(test)]
772        {
773            let thread_id = std::thread::current().id();
774            guard.test_policies.get(&thread_id).cloned()
775        }
776        #[cfg(not(test))]
777        {
778            guard.policy.clone()
779        }
780    };
781    let (ssrf_mode, _allow_loopback) =
782        effective_ssrf_settings(configured.as_ref().map(|c| &c.policy));
783    let block_private = ssrf_mode == SsrfMode::BlockPrivate;
784    // Does the policy carry any IP/CIDR deny rule (port-scoped or not)? Such a
785    // rule can only ever change the outcome via the resolved-IP layer.
786    let has_ip_deny = configured
787        .as_ref()
788        .is_some_and(|c| c.policy.deny.iter().any(EgressRule::is_ip_matcher));
789
790    // Nothing on the resolved-IP axis can change the outcome: defer entirely.
791    // (`resolution_required` already implies a deferred allow decision.)
792    if !block_private && !has_ip_deny && !resolution_required {
793        return Ok(None);
794    }
795
796    let target = EgressTarget::parse(raw_url)?;
797    if target.ip.is_some() {
798        // Literal IPs are already fully handled synchronously in `check_url`
799        // (both the SSRF block and the IP/CIDR rules).
800        return Ok(None);
801    }
802
803    let Some(addrs) = resolve_host_addrs(&target.host).await else {
804        // Unresolvable here. If a resolved-IP allow rule was the host's only
805        // path to approval we must block; otherwise defer to the connect-time
806        // guard (which will itself fail to connect to a disallowed address).
807        if resolution_required {
808            return Ok(Some(blocked(
809                surface,
810                raw_url,
811                &target,
812                "no allow rule matched".to_string(),
813            )));
814        }
815        return Ok(None);
816    };
817
818    Ok(evaluate_resolved_addrs(
819        configured.as_ref(),
820        surface,
821        raw_url,
822        &target,
823        &addrs,
824        resolution_required,
825    ))
826}
827
828/// Pure resolved-address policy evaluation, factored out of
829/// [`check_url_host_resolution`] so it can be unit-tested with synthetic
830/// addresses (no real DNS). `configured` is the effective policy; `addrs` are
831/// the host's resolved addresses.
832///
833/// Resolve-once-and-pin: callers pass a SINGLE resolution and every decision
834/// below uses exactly those addresses. The connect-time [`GuardedResolver`]
835/// re-applies the same SSRF + deny predicates to whatever address reqwest pins
836/// the socket to, so the two layers cannot disagree under DNS rebinding.
837fn evaluate_resolved_addrs(
838    configured: Option<&ConfiguredPolicy>,
839    surface: &str,
840    raw_url: &str,
841    target: &EgressTarget,
842    addrs: &[IpAddr],
843    resolution_required: bool,
844) -> Option<EgressBlocked> {
845    let (ssrf_mode, allow_loopback) = effective_ssrf_settings(configured.map(|c| &c.policy));
846    let block_private = ssrf_mode == SsrfMode::BlockPrivate;
847    let allow_active = configured.is_some_and(|c| c.policy.default == DefaultAction::Deny);
848
849    // (1) SSRF private-address block: if ANY resolved address is disallowed,
850    //     block (a private address among the answers is a rebinding vector).
851    if block_private && addrs.iter().any(|ip| is_disallowed_ip(*ip, allow_loopback)) {
852        return Some(blocked(
853            surface,
854            raw_url,
855            target,
856            private_block_reason(&target.host),
857        ));
858    }
859
860    // (2) NetPolicy deny CIDR/IP wins over allow: if ANY resolved address
861    //     matches a deny IP/CIDR rule (port-aware), block. This is the gap
862    //     #3174 covers: a hostname that resolves into a denied CIDR must be
863    //     blocked. Matching against the rule list (rather than pre-reduced
864    //     nets) preserves per-rule `:port` scoping.
865    if let Some(rule) = configured.and_then(|c| {
866        c.policy.deny.iter().find(|rule| {
867            rule.is_ip_matcher()
868                && addrs
869                    .iter()
870                    .any(|ip| rule.matches_resolved_ip(*ip, target.port))
871        })
872    }) {
873        return Some(blocked(
874            surface,
875            raw_url,
876            target,
877            format!("resolved address matched deny rule `{}`", rule.raw),
878        ));
879    }
880
881    // (3) Allowlist gating: when a default-deny allowlist is in effect and the
882    //     host was NOT already granted at the URL layer (`resolution_required`),
883    //     the host is allowed only if a resolved address matches an allow
884    //     IP/CIDR rule (port-aware). Without one, reject.
885    if resolution_required && allow_active {
886        let permitted = configured.is_some_and(|c| {
887            c.policy.allow.iter().any(|rule| {
888                rule.is_ip_matcher()
889                    && addrs
890                        .iter()
891                        .any(|ip| rule.matches_resolved_ip(*ip, target.port))
892            })
893        });
894        if !permitted {
895            return Some(blocked(
896                surface,
897                raw_url,
898                target,
899                "no allow rule matched".to_string(),
900            ));
901        }
902    }
903
904    None
905}
906
907fn blocked(surface: &str, url: &str, target: &EgressTarget, reason: String) -> EgressBlocked {
908    EgressBlocked {
909        surface: surface.to_string(),
910        url: redact_sensitive_url(url),
911        host: target.host.clone(),
912        port: target.port,
913        reason,
914    }
915}
916
917async fn audit_blocked(blocked: &EgressBlocked) {
918    let Some(log) = active_event_log() else {
919        return;
920    };
921    let Ok(topic) = Topic::new(EGRESS_AUDIT_TOPIC) else {
922        return;
923    };
924    let payload = json!({
925        "surface": blocked.surface,
926        "url": blocked.url,
927        "host": blocked.host,
928        "port": blocked.port,
929        "reason": blocked.reason,
930        "error_type": "EgressBlocked",
931    });
932    let _ = log
933        .append(&topic, LogEvent::new("egress.blocked", payload))
934        .await;
935}
936
937fn audit_blocked_background(blocked: EgressBlocked) {
938    let Some(log) = active_event_log() else {
939        return;
940    };
941    let Ok(topic) = Topic::new(EGRESS_AUDIT_TOPIC) else {
942        return;
943    };
944    if let Ok(handle) = tokio::runtime::Handle::try_current() {
945        handle.spawn(async move {
946            let payload = json!({
947                "surface": blocked.surface,
948                "url": blocked.url,
949                "host": blocked.host,
950                "port": blocked.port,
951                "reason": blocked.reason,
952                "error_type": "EgressBlocked",
953            });
954            let _ = log
955                .append(&topic, LogEvent::new("egress.blocked", payload))
956                .await;
957        });
958    }
959}
960
961fn install_policy(policy: EgressPolicy, source: &'static str) -> Result<(), VmError> {
962    ensure_env_seeded()?;
963    let mut guard = state().write().expect("egress policy state poisoned");
964    #[cfg(test)]
965    {
966        let thread_id = std::thread::current().id();
967        if let Some(existing) = guard.test_policies.get(&thread_id) {
968            return Err(vm_error(format!(
969                "egress_policy: policy already configured from {}",
970                existing.source
971            )));
972        }
973        guard
974            .test_policies
975            .insert(thread_id, ConfiguredPolicy { source, policy });
976        Ok(())
977    }
978    #[cfg(not(test))]
979    {
980        if let Some(existing) = &guard.policy {
981            return Err(vm_error(format!(
982                "egress_policy: policy already configured from {}",
983                existing.source
984            )));
985        }
986        guard.policy = Some(ConfiguredPolicy { source, policy });
987        Ok(())
988    }
989}
990
991fn ensure_env_seeded() -> Result<(), VmError> {
992    {
993        let guard = state().read().expect("egress policy state poisoned");
994        #[cfg(test)]
995        {
996            if guard
997                .test_env_checked
998                .contains(&std::thread::current().id())
999            {
1000                return Ok(());
1001            }
1002        }
1003        #[cfg(not(test))]
1004        {
1005            if guard.env_checked {
1006                return Ok(());
1007            }
1008        }
1009    }
1010
1011    let allow = std::env::var(HARN_EGRESS_ALLOW_ENV).ok();
1012    let deny = std::env::var(HARN_EGRESS_DENY_ENV).ok();
1013    let default = std::env::var(HARN_EGRESS_DEFAULT_ENV).ok();
1014    let block_private = std::env::var(HARN_EGRESS_BLOCK_PRIVATE_ENV).ok();
1015    let allow_loopback = std::env::var(HARN_EGRESS_ALLOW_LOOPBACK_ENV).ok();
1016    let any_set = allow.is_some()
1017        || deny.is_some()
1018        || default.is_some()
1019        || block_private.is_some()
1020        || allow_loopback.is_some();
1021    let build_policy = || -> Result<EgressPolicy, VmError> {
1022        Ok(EgressPolicy {
1023            allow: parse_rule_list(allow.as_deref().unwrap_or(""))?,
1024            deny: parse_rule_list(deny.as_deref().unwrap_or(""))?,
1025            default: parse_default_action(default.as_deref().unwrap_or("allow"))?,
1026            block_private: block_private.as_deref().map(parse_ssrf_mode).transpose()?,
1027            allow_loopback: allow_loopback
1028                .as_deref()
1029                .map(parse_bool)
1030                .transpose()?
1031                .unwrap_or(false),
1032        })
1033    };
1034    let mut guard = state().write().expect("egress policy state poisoned");
1035    #[cfg(test)]
1036    {
1037        let thread_id = std::thread::current().id();
1038        if guard.test_env_checked.contains(&thread_id) {
1039            return Ok(());
1040        }
1041        guard.test_env_checked.insert(thread_id);
1042        if !any_set {
1043            return Ok(());
1044        }
1045        guard.test_policies.insert(
1046            thread_id,
1047            ConfiguredPolicy {
1048                source: "environment",
1049                policy: build_policy()?,
1050            },
1051        );
1052        Ok(())
1053    }
1054    #[cfg(not(test))]
1055    {
1056        if guard.env_checked {
1057            return Ok(());
1058        }
1059        guard.env_checked = true;
1060        if !any_set {
1061            return Ok(());
1062        }
1063        guard.policy = Some(ConfiguredPolicy {
1064            source: "environment",
1065            policy: build_policy()?,
1066        });
1067        Ok(())
1068    }
1069}
1070
1071fn policy_from_config(config: &crate::value::DictMap) -> Result<EgressPolicy, VmError> {
1072    let allow = match config.get("allow") {
1073        Some(VmValue::List(items)) => parse_rule_values(items)?,
1074        Some(VmValue::Nil) => Vec::new(),
1075        Some(_) => return Err(vm_error("egress_policy: allow must be a list")),
1076        None => Vec::new(),
1077    };
1078    let deny = match config.get("deny") {
1079        Some(VmValue::List(items)) => parse_rule_values(items)?,
1080        Some(VmValue::Nil) => Vec::new(),
1081        Some(_) => return Err(vm_error("egress_policy: deny must be a list")),
1082        None => Vec::new(),
1083    };
1084    let default = config
1085        .get("default")
1086        .map(|value| parse_default_action(&value.display()))
1087        .transpose()?
1088        .unwrap_or(DefaultAction::Allow);
1089    let block_private = config
1090        .get("block_private")
1091        .map(|value| parse_ssrf_mode(&value.display()))
1092        .transpose()?;
1093    let allow_loopback = match config.get("allow_loopback") {
1094        Some(value) => parse_bool(&value.display())?,
1095        None => false,
1096    };
1097    Ok(EgressPolicy {
1098        allow,
1099        deny,
1100        default,
1101        block_private,
1102        allow_loopback,
1103    })
1104}
1105
1106fn parse_ssrf_mode(raw: &str) -> Result<SsrfMode, VmError> {
1107    match raw.trim().to_ascii_lowercase().as_str() {
1108        // `private` and `on` engage the private-address block; `off` opts out.
1109        "private" | "on" | "block" | "block_private" | "true" => Ok(SsrfMode::BlockPrivate),
1110        "off" | "false" | "none" => Ok(SsrfMode::Off),
1111        other => Err(vm_error(format!(
1112            "egress_policy: block_private must be `private`/`on` or `off`, got `{other}`"
1113        ))),
1114    }
1115}
1116
1117fn parse_bool(raw: &str) -> Result<bool, VmError> {
1118    match raw.trim().to_ascii_lowercase().as_str() {
1119        "true" | "1" | "yes" | "on" => Ok(true),
1120        "false" | "0" | "no" | "off" | "" => Ok(false),
1121        other => Err(vm_error(format!(
1122            "egress_policy: allow_loopback must be a boolean, got `{other}`"
1123        ))),
1124    }
1125}
1126
1127fn parse_rule_values(values: &[VmValue]) -> Result<Vec<EgressRule>, VmError> {
1128    values
1129        .iter()
1130        .map(|value| EgressRule::parse(&value.display()))
1131        .collect()
1132}
1133
1134fn parse_rule_list(raw: &str) -> Result<Vec<EgressRule>, VmError> {
1135    raw.split([',', '\n', ';'])
1136        .map(str::trim)
1137        .filter(|part| !part.is_empty())
1138        .map(EgressRule::parse)
1139        .collect()
1140}
1141
1142fn parse_default_action(raw: &str) -> Result<DefaultAction, VmError> {
1143    match raw.trim().to_ascii_lowercase().as_str() {
1144        "" | "allow" => Ok(DefaultAction::Allow),
1145        "deny" => Ok(DefaultAction::Deny),
1146        other => Err(vm_error(format!(
1147            "egress_policy: default must be `allow` or `deny`, got `{other}`"
1148        ))),
1149    }
1150}
1151
1152fn policy_summary() -> VmValue {
1153    let configured = {
1154        let guard = state().read().expect("egress policy state poisoned");
1155        #[cfg(test)]
1156        {
1157            guard
1158                .test_policies
1159                .get(&std::thread::current().id())
1160                .cloned()
1161        }
1162        #[cfg(not(test))]
1163        {
1164            guard.policy.clone()
1165        }
1166    };
1167    let mut dict = BTreeMap::new();
1168    if let Some(configured) = configured {
1169        dict.insert("configured".to_string(), VmValue::Bool(true));
1170        dict.put_str("source", configured.source);
1171        dict.put_str(
1172            "default",
1173            match configured.policy.default {
1174                DefaultAction::Allow => "allow",
1175                DefaultAction::Deny => "deny",
1176            },
1177        );
1178        dict.insert(
1179            "allow".to_string(),
1180            VmValue::List(std::sync::Arc::new(
1181                configured
1182                    .policy
1183                    .allow
1184                    .iter()
1185                    .map(|rule| VmValue::String(arcstr::ArcStr::from(rule.raw.as_str())))
1186                    .collect(),
1187            )),
1188        );
1189        dict.insert(
1190            "deny".to_string(),
1191            VmValue::List(std::sync::Arc::new(
1192                configured
1193                    .policy
1194                    .deny
1195                    .iter()
1196                    .map(|rule| VmValue::String(arcstr::ArcStr::from(rule.raw.as_str())))
1197                    .collect(),
1198            )),
1199        );
1200        let (mode, allow_loopback) = effective_ssrf_settings(Some(&configured.policy));
1201        dict.put_str(
1202            "block_private",
1203            match mode {
1204                SsrfMode::BlockPrivate => "private",
1205                SsrfMode::Off => "off",
1206            },
1207        );
1208        dict.insert("allow_loopback".to_string(), VmValue::Bool(allow_loopback));
1209    } else {
1210        dict.insert("configured".to_string(), VmValue::Bool(false));
1211    }
1212    VmValue::dict(dict)
1213}
1214
1215impl EgressRule {
1216    fn parse(raw: &str) -> Result<Self, VmError> {
1217        let raw = raw.trim();
1218        if raw.is_empty() {
1219            return Err(vm_error("egress_policy: empty egress rule"));
1220        }
1221        let (host, port) = parse_rule_host_port(raw)?;
1222        let host = normalize_host(&host);
1223        let matcher = if let Some(suffix) = host.strip_prefix("*.") {
1224            if suffix.is_empty() {
1225                return Err(vm_error(format!(
1226                    "egress_policy: invalid wildcard rule `{raw}`"
1227                )));
1228            }
1229            EgressMatcher::Suffix(suffix.to_string())
1230        } else if host.contains('/') {
1231            EgressMatcher::Cidr(IpNet::from_str(&host).map_err(|error| {
1232                vm_error(format!("egress_policy: invalid CIDR rule `{raw}`: {error}"))
1233            })?)
1234        } else if let Ok(ip) = IpAddr::from_str(&host) {
1235            EgressMatcher::Ip(ip)
1236        } else {
1237            EgressMatcher::Host(host)
1238        };
1239        Ok(Self {
1240            raw: raw.to_string(),
1241            matcher,
1242            port,
1243        })
1244    }
1245
1246    fn matches(&self, target: &EgressTarget) -> bool {
1247        if let Some(port) = self.port {
1248            if target.port != Some(port) {
1249                return false;
1250            }
1251        }
1252        match &self.matcher {
1253            EgressMatcher::Host(host) => target.host == *host,
1254            EgressMatcher::Suffix(suffix) => {
1255                crate::harness_net::host_has_dns_suffix(&target.host, suffix)
1256            }
1257            EgressMatcher::Ip(ip) => target.ip == Some(*ip),
1258            EgressMatcher::Cidr(net) => target.ip.is_some_and(|ip| net.contains(&ip)),
1259        }
1260    }
1261
1262    /// True when this rule is an IP-literal or CIDR matcher (the only rule
1263    /// kinds that can be evaluated against a *resolved* address). Host and
1264    /// suffix matchers can only ever match the URL hostname, so they are out
1265    /// of scope for the resolved-IP layer.
1266    fn is_ip_matcher(&self) -> bool {
1267        matches!(self.matcher, EgressMatcher::Ip(_) | EgressMatcher::Cidr(_))
1268    }
1269
1270    /// Whether this IP/CIDR rule matches a *resolved* address for the given
1271    /// request port. Host/suffix rules never match here (they pin to the URL
1272    /// hostname, not the address it resolves to). Returns `false` for non-IP
1273    /// matchers so callers can blanket-iterate the rule list.
1274    fn matches_resolved_ip(&self, ip: IpAddr, request_port: Option<u16>) -> bool {
1275        if let Some(port) = self.port {
1276            if request_port != Some(port) {
1277                return false;
1278            }
1279        }
1280        match &self.matcher {
1281            EgressMatcher::Ip(rule_ip) => *rule_ip == ip,
1282            EgressMatcher::Cidr(net) => net.contains(&ip),
1283            EgressMatcher::Host(_) | EgressMatcher::Suffix(_) => false,
1284        }
1285    }
1286}
1287
1288#[derive(Clone, Debug)]
1289struct EgressTarget {
1290    host: String,
1291    ip: Option<IpAddr>,
1292    port: Option<u16>,
1293}
1294
1295impl EgressTarget {
1296    fn parse(raw_url: &str) -> Result<Self, VmError> {
1297        let parsed = Url::parse(raw_url)
1298            .map_err(|error| vm_error(format!("egress: invalid URL `{raw_url}`: {error}")))?;
1299        let host = parsed
1300            .host_str()
1301            .ok_or_else(|| vm_error(format!("egress: URL `{raw_url}` does not include a host")))?;
1302        let host = normalize_host(host);
1303        let ip = IpAddr::from_str(&host).ok();
1304        Ok(Self {
1305            host,
1306            ip,
1307            port: parsed.port_or_known_default(),
1308        })
1309    }
1310
1311    fn is_loopback_host(&self) -> bool {
1312        self.host == "localhost"
1313            || self.ip.is_some_and(|ip| match ip {
1314                IpAddr::V4(v4) => v4.is_loopback(),
1315                IpAddr::V6(v6) => {
1316                    v6.is_loopback()
1317                        || v6
1318                            .to_ipv4_mapped()
1319                            .is_some_and(|mapped| mapped.is_loopback())
1320                }
1321            })
1322    }
1323}
1324
1325fn parse_rule_host_port(raw: &str) -> Result<(String, Option<u16>), VmError> {
1326    if let Ok(url) = Url::parse(raw) {
1327        if let Some(host) = url.host_str() {
1328            return Ok((host.to_string(), url.port_or_known_default()));
1329        }
1330    }
1331    let raw = raw.trim();
1332    if let Some(rest) = raw.strip_prefix('[') {
1333        let Some((host, suffix)) = rest.split_once(']') else {
1334            return Err(vm_error(format!(
1335                "egress_policy: invalid bracketed host rule `{raw}`"
1336            )));
1337        };
1338        let port = if let Some(port) = suffix.strip_prefix(':') {
1339            Some(parse_port(raw, port)?)
1340        } else if suffix.is_empty() {
1341            None
1342        } else {
1343            return Err(vm_error(format!(
1344                "egress_policy: invalid bracketed host rule `{raw}`"
1345            )));
1346        };
1347        return Ok((host.to_string(), port));
1348    }
1349    if let Some((host, port)) = split_host_port(raw) {
1350        return Ok((host.to_string(), Some(parse_port(raw, port)?)));
1351    }
1352    Ok((raw.to_string(), None))
1353}
1354
1355fn split_host_port(raw: &str) -> Option<(&str, &str)> {
1356    let (host, port) = raw.rsplit_once(':')?;
1357    if host.contains(':') || port.is_empty() || !port.chars().all(|ch| ch.is_ascii_digit()) {
1358        return None;
1359    }
1360    Some((host, port))
1361}
1362
1363fn parse_port(rule: &str, raw: &str) -> Result<u16, VmError> {
1364    raw.parse::<u16>()
1365        .map_err(|error| vm_error(format!("egress_policy: invalid port in `{rule}`: {error}")))
1366}
1367
1368fn normalize_host(host: &str) -> String {
1369    host.trim()
1370        .trim_end_matches('.')
1371        .trim_matches('[')
1372        .trim_matches(']')
1373        .to_ascii_lowercase()
1374}
1375
1376fn redact_sensitive_url(url: &str) -> String {
1377    crate::redact::current_policy().redact_url(url)
1378}
1379
1380fn vm_error(message: impl Into<String>) -> VmError {
1381    VmError::Thrown(VmValue::String(arcstr::ArcStr::from(message.into())))
1382}
1383
1384impl EgressBlocked {
1385    pub(crate) fn to_vm_error(&self) -> VmError {
1386        let mut dict = BTreeMap::new();
1387        dict.put_str("type", "EgressBlocked");
1388        dict.put_str("category", "egress_blocked");
1389        dict.put_str("message", self.to_string());
1390        dict.put_str("surface", self.surface.as_str());
1391        dict.put_str("url", self.url.as_str());
1392        dict.put_str("host", self.host.as_str());
1393        dict.insert(
1394            "port".to_string(),
1395            self.port
1396                .map(|port| VmValue::Int(port as i64))
1397                .unwrap_or(VmValue::Nil),
1398        );
1399        dict.put_str("reason", self.reason.as_str());
1400        VmError::Thrown(VmValue::dict(dict))
1401    }
1402}
1403
1404impl std::fmt::Display for EgressBlocked {
1405    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1406        match self.port {
1407            Some(port) => write!(
1408                f,
1409                "EgressBlocked: {} blocked {}:{} for {} ({})",
1410                self.surface, self.host, port, self.url, self.reason
1411            ),
1412            None => write!(
1413                f,
1414                "EgressBlocked: {} blocked {} for {} ({})",
1415                self.surface, self.host, self.url, self.reason
1416            ),
1417        }
1418    }
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423    use super::*;
1424    use rcgen::generate_simple_self_signed;
1425    use rustls::pki_types::PrivatePkcs8KeyDer;
1426    use rustls::{ServerConfig, ServerConnection, StreamOwned};
1427    use std::io::{Read, Write};
1428    use std::net::TcpListener;
1429    use std::sync::{Arc, Once};
1430
1431    fn install(config: &[(&str, VmValue)]) -> std::sync::MutexGuard<'static, ()> {
1432        let guard = test_env_lock();
1433        reset_egress_policy_for_tests();
1434        let map = config
1435            .iter()
1436            .cloned()
1437            .map(|(key, value)| (key.to_string(), value))
1438            .collect();
1439        let policy = policy_from_config(&map).expect("policy parses");
1440        install_policy(policy, "test").expect("policy installs");
1441        guard
1442    }
1443
1444    fn strings(values: &[&str]) -> VmValue {
1445        VmValue::List(std::sync::Arc::new(
1446            values
1447                .iter()
1448                .map(|value| VmValue::String(arcstr::ArcStr::from(*value)))
1449                .collect(),
1450        ))
1451    }
1452
1453    #[test]
1454    fn exact_host_and_port_restriction() {
1455        let _guard = install(&[
1456            ("allow", strings(&["api.example.com:443"])),
1457            ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
1458        ]);
1459        assert!(check_url("http_get", "https://api.example.com/users")
1460            .unwrap()
1461            .is_none());
1462        let blocked = check_url("http_get", "http://api.example.com/users")
1463            .unwrap()
1464            .expect("port mismatch blocks");
1465        assert_eq!(blocked.host, "api.example.com");
1466        assert_eq!(blocked.port, Some(80));
1467    }
1468
1469    #[test]
1470    fn suffix_wildcard_matches_subdomains_only() {
1471        let _guard = install(&[
1472            ("allow", strings(&["*.example.com"])),
1473            ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
1474        ]);
1475        assert!(check_url("http_get", "https://api.example.com")
1476            .unwrap()
1477            .is_none());
1478        assert!(check_url("http_get", "https://example.com")
1479            .unwrap()
1480            .is_some());
1481    }
1482
1483    #[test]
1484    fn cidr_matches_ip_literals() {
1485        let _guard = install(&[
1486            ("allow", strings(&["127.0.0.0/8"])),
1487            ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
1488        ]);
1489        assert!(check_url("http_get", "http://127.10.20.30:8080")
1490            .unwrap()
1491            .is_none());
1492        assert!(check_url("http_get", "http://192.168.1.1")
1493            .unwrap()
1494            .is_some());
1495    }
1496
1497    #[test]
1498    fn deny_overrides_allow() {
1499        let _guard = install(&[
1500            ("allow", strings(&["*.example.com"])),
1501            ("deny", strings(&["blocked.example.com"])),
1502            ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
1503        ]);
1504        let blocked = check_url("http_get", "https://blocked.example.com")
1505            .unwrap()
1506            .expect("deny wins");
1507        assert!(blocked.reason.contains("deny rule"));
1508    }
1509
1510    #[test]
1511    fn blocked_urls_redact_sensitive_query_values() {
1512        let _guard = install(&[("default", VmValue::String(arcstr::ArcStr::from("deny")))]);
1513        let blocked = check_url(
1514            "http_get",
1515            "https://api.example.com/resource?access_token=secret-token&ok=1",
1516        )
1517        .unwrap()
1518        .expect("default deny blocks");
1519
1520        assert_eq!(
1521            blocked.url,
1522            "https://api.example.com/resource?access_token=%5Bredacted%5D&ok=1"
1523        );
1524        assert!(!blocked.to_string().contains("secret-token"));
1525    }
1526
1527    #[test]
1528    fn require_explicit_policy_blocks_unconfigured_egress() {
1529        let _guard = test_env_lock();
1530        reset_egress_policy_for_tests();
1531        {
1532            let _scope = require_explicit_egress_policy_for_host();
1533            let blocked = check_url("http_get", "https://api.example.com/status")
1534                .unwrap()
1535                .expect("missing policy blocks");
1536            assert_eq!(blocked.reason, "no egress policy configured");
1537            assert_eq!(blocked.host, "api.example.com");
1538        }
1539        assert!(check_url("http_get", "https://api.example.com/status")
1540            .unwrap()
1541            .is_none());
1542        reset_egress_policy_for_tests();
1543    }
1544
1545    #[test]
1546    fn reset_thread_local_state_clears_required_policy_scope() {
1547        let _guard = test_env_lock();
1548        reset_egress_policy_for_tests();
1549        let _scope = require_explicit_egress_policy_for_host();
1550
1551        crate::reset_thread_local_state();
1552
1553        assert!(check_url("http_get", "https://api.example.com/status")
1554            .unwrap()
1555            .is_none());
1556        reset_egress_policy_for_tests();
1557    }
1558
1559    #[test]
1560    fn explicit_environment_policy_satisfies_required_policy_scope() {
1561        let _guard = test_env_lock();
1562        reset_egress_policy_for_tests();
1563        std::env::set_var(HARN_EGRESS_ALLOW_ENV, "api.example.com");
1564        std::env::set_var(HARN_EGRESS_DEFAULT_ENV, "deny");
1565        let _scope = require_explicit_egress_policy_for_host();
1566
1567        assert!(check_url("http_get", "https://api.example.com/status")
1568            .unwrap()
1569            .is_none());
1570        assert!(check_url("http_get", "https://other.example.com/status")
1571            .unwrap()
1572            .is_some());
1573
1574        std::env::remove_var(HARN_EGRESS_ALLOW_ENV);
1575        std::env::remove_var(HARN_EGRESS_DEFAULT_ENV);
1576        reset_egress_policy_for_tests();
1577    }
1578
1579    #[test]
1580    fn env_seeding_is_honored() {
1581        let _guard = test_env_lock();
1582        reset_egress_policy_for_tests();
1583        std::env::set_var(HARN_EGRESS_ALLOW_ENV, "");
1584        std::env::set_var(HARN_EGRESS_DENY_ENV, "blocked-env.example.com");
1585        std::env::set_var(HARN_EGRESS_DEFAULT_ENV, "allow");
1586        assert!(check_url("http_get", "https://env.example.com")
1587            .unwrap()
1588            .is_none());
1589        assert!(check_url("http_get", "https://blocked-env.example.com")
1590            .unwrap()
1591            .is_some());
1592        std::env::remove_var(HARN_EGRESS_ALLOW_ENV);
1593        std::env::remove_var(HARN_EGRESS_DENY_ENV);
1594        std::env::remove_var(HARN_EGRESS_DEFAULT_ENV);
1595        reset_egress_policy_for_tests();
1596    }
1597
1598    #[tokio::test]
1599    async fn reqwest_redirect_policy_blocks_https_to_http_downgrade() {
1600        install_rustls_provider();
1601
1602        let cert =
1603            generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()])
1604                .expect("generate cert");
1605        let listener = TcpListener::bind("127.0.0.1:0").expect("bind tls listener");
1606        let port = listener.local_addr().expect("tls addr").port();
1607        let server_config = Arc::new(
1608            ServerConfig::builder()
1609                .with_no_client_auth()
1610                .with_single_cert(
1611                    vec![cert.cert.der().clone()],
1612                    PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()).into(),
1613                )
1614                .expect("build tls config"),
1615        );
1616        let thread = std::thread::spawn(move || {
1617            let (tcp, _) = listener.accept().expect("accept tls client");
1618            let conn = ServerConnection::new(server_config).expect("server connection");
1619            let mut stream = StreamOwned::new(conn, tcp);
1620            let request = read_http_request(&mut stream);
1621            assert!(request.starts_with("GET /start HTTP/1.1\r\n"));
1622            write_http_redirect_response(
1623                &mut stream,
1624                "http://public.example.com/next?access_token=target-secret",
1625            );
1626        });
1627
1628        let client = reqwest::Client::builder()
1629            .danger_accept_invalid_certs(true)
1630            .timeout(std::time::Duration::from_secs(2))
1631            .redirect(redirect_policy("test_redirect", 10))
1632            .build()
1633            .expect("client builds");
1634        let error = client
1635            .get(format!("https://localhost:{port}/start"))
1636            .send()
1637            .await
1638            .expect_err("downgrade redirect should be blocked by policy");
1639        assert!(error.is_redirect(), "{error}");
1640        let redacted = redact_reqwest_error(&error);
1641        assert!(!redacted.contains("target-secret"), "{redacted}");
1642
1643        thread.join().expect("tls thread");
1644    }
1645
1646    #[test]
1647    fn redirect_blocks_public_https_to_http_downgrade() {
1648        let _guard = test_env_lock();
1649        reset_egress_policy_for_tests();
1650        let blocked = check_redirect_url(
1651            "http_redirect",
1652            Some("https://api.example.com/start?access_token=source-secret"),
1653            "http://public.example.com/next?access_token=target-secret",
1654        )
1655        .unwrap()
1656        .expect("public https-to-http redirect is blocked");
1657
1658        assert_eq!(blocked.host, "public.example.com");
1659        assert_eq!(blocked.port, Some(80));
1660        assert_eq!(
1661            blocked.reason,
1662            "https redirect target downgrades to insecure http"
1663        );
1664        assert!(!blocked.url.contains("target-secret"), "{}", blocked.url);
1665        reset_egress_policy_for_tests();
1666    }
1667
1668    #[test]
1669    fn redirect_allows_same_scheme_and_secure_upgrade() {
1670        let _guard = test_env_lock();
1671        reset_egress_policy_for_tests();
1672
1673        for (previous, target) in [
1674            (
1675                "https://api.example.com/start",
1676                "https://cdn.example.com/next",
1677            ),
1678            (
1679                "http://api.example.com/start",
1680                "http://cdn.example.com/next",
1681            ),
1682            (
1683                "http://api.example.com/start",
1684                "https://cdn.example.com/next",
1685            ),
1686        ] {
1687            assert!(
1688                check_redirect_url("http_redirect", Some(previous), target)
1689                    .unwrap()
1690                    .is_none(),
1691                "{previous} -> {target} should be allowed"
1692            );
1693        }
1694
1695        reset_egress_policy_for_tests();
1696    }
1697
1698    #[test]
1699    fn loopback_https_to_http_redirect_requires_loopback_hatch() {
1700        let _guard = test_env_lock();
1701        reset_egress_policy_for_tests();
1702        assert!(
1703            check_redirect_url(
1704                "http_redirect",
1705                Some("https://api.example.com/start"),
1706                "http://127.0.0.1:7777/callback",
1707            )
1708            .unwrap()
1709            .is_some(),
1710            "loopback downgrade is blocked without the explicit loopback hatch"
1711        );
1712
1713        install_test_policy(&[
1714            (
1715                "block_private",
1716                VmValue::String(arcstr::ArcStr::from("private")),
1717            ),
1718            ("allow_loopback", VmValue::Bool(true)),
1719        ]);
1720        assert!(
1721            check_redirect_url(
1722                "http_redirect",
1723                Some("https://api.example.com/start"),
1724                "http://127.0.0.1:7777/callback",
1725            )
1726            .unwrap()
1727            .is_none(),
1728            "explicit allow_loopback keeps loopback redirect workflows available"
1729        );
1730        assert!(
1731            check_redirect_url(
1732                "http_redirect",
1733                Some("https://api.example.com/start"),
1734                "http://LOCALHOST.:7777/callback",
1735            )
1736            .unwrap()
1737            .is_none(),
1738            "localhost follows the same explicit loopback hatch after normalization"
1739        );
1740        assert!(
1741            check_redirect_url(
1742                "http_redirect",
1743                Some("https://api.example.com/start"),
1744                "http://[::1]:7777/callback",
1745            )
1746            .unwrap()
1747            .is_none(),
1748            "IPv6 loopback follows the same explicit loopback hatch"
1749        );
1750        assert!(
1751            check_redirect_url(
1752                "http_redirect",
1753                Some("https://api.example.com/start"),
1754                "http://[::ffff:127.0.0.1]:7777/callback",
1755            )
1756            .unwrap()
1757            .is_none(),
1758            "IPv4-mapped IPv6 loopback follows the same explicit loopback hatch"
1759        );
1760        assert!(
1761            check_redirect_url(
1762                "http_redirect",
1763                Some("https://api.example.com/start"),
1764                "http://public.example.com/callback",
1765            )
1766            .unwrap()
1767            .is_some(),
1768            "allow_loopback does not exempt public insecure redirects"
1769        );
1770
1771        reset_egress_policy_for_tests();
1772    }
1773
1774    fn install_rustls_provider() {
1775        static INSTALL: Once = Once::new();
1776        INSTALL.call_once(|| {
1777            let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
1778        });
1779    }
1780
1781    fn read_http_request<T: Read>(stream: &mut T) -> String {
1782        let mut buffer = Vec::new();
1783        let mut chunk = [0u8; 4096];
1784        loop {
1785            let read = stream.read(&mut chunk).expect("read request");
1786            assert!(read > 0, "request closed before headers");
1787            buffer.extend_from_slice(&chunk[..read]);
1788            if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
1789                return String::from_utf8_lossy(&buffer).into_owned();
1790            }
1791        }
1792    }
1793
1794    fn write_http_redirect_response<T: Write>(stream: &mut T, location: &str) {
1795        let response = format!(
1796            "HTTP/1.1 302 Found\r\ncontent-length: 0\r\nconnection: close\r\nlocation: {location}\r\n\r\n"
1797        );
1798        stream
1799            .write_all(response.as_bytes())
1800            .expect("write redirect response");
1801        stream.flush().expect("flush redirect response");
1802    }
1803
1804    // --- SSRF private-address block (literal IPs; host-name DNS is covered by
1805    //     GuardedResolver tests in `ssrf.rs`). ---
1806
1807    /// Install an explicit `block_private:"private"` policy with default allow,
1808    /// so the SSRF block is the only thing that can deny.
1809    fn install_block_private() -> std::sync::MutexGuard<'static, ()> {
1810        install(&[(
1811            "block_private",
1812            VmValue::String(arcstr::ArcStr::from("private")),
1813        )])
1814    }
1815
1816    #[test]
1817    fn block_private_rejects_loopback_and_metadata_literals() {
1818        let _guard = install_block_private();
1819        for url in [
1820            "http://127.0.0.1",
1821            "https://169.254.169.254",
1822            "http://10.0.0.1",
1823            "https://192.168.1.1",
1824            "https://[::1]",
1825            "https://[fe80::1]",
1826            "https://[::ffff:127.0.0.1]",
1827            "https://100.64.0.1",
1828        ] {
1829            let blocked = check_url("http_get", url)
1830                .unwrap()
1831                .unwrap_or_else(|| panic!("expected block for {url}"));
1832            assert!(
1833                blocked.reason.contains("disallowed address"),
1834                "{url}: {}",
1835                blocked.reason
1836            );
1837        }
1838    }
1839
1840    #[test]
1841    fn block_private_allows_public_literal() {
1842        let _guard = install_block_private();
1843        assert!(check_url("http_get", "https://93.184.216.34")
1844            .unwrap()
1845            .is_none());
1846        assert!(check_url("http_get", "http://8.8.8.8").unwrap().is_none());
1847    }
1848
1849    #[test]
1850    fn block_private_wins_over_allow_rule() {
1851        // Deny-private wins: an explicit allow for the literal must NOT re-open it.
1852        let _guard = install(&[
1853            ("allow", strings(&["127.0.0.1"])),
1854            (
1855                "block_private",
1856                VmValue::String(arcstr::ArcStr::from("private")),
1857            ),
1858            ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
1859        ]);
1860        assert!(check_url("http_get", "http://127.0.0.1").unwrap().is_some());
1861    }
1862
1863    #[test]
1864    fn block_private_redacts_secret_in_url() {
1865        let _guard = install_block_private();
1866        let blocked = check_url(
1867            "http_get",
1868            "http://127.0.0.1/resource?token=SECRET&access_token=zzz&ok=1",
1869        )
1870        .unwrap()
1871        .expect("loopback blocked");
1872        assert!(!blocked.url.contains("SECRET"), "{}", blocked.url);
1873        assert!(!blocked.url.contains("zzz"), "{}", blocked.url);
1874        assert!(!blocked.to_string().contains("SECRET"));
1875        assert!(!blocked.reason.contains("SECRET"));
1876        // Reason names only the host.
1877        assert!(blocked.reason.contains("127.0.0.1"));
1878    }
1879
1880    #[test]
1881    fn allow_loopback_hatch_permits_loopback_literal() {
1882        let _guard = install(&[
1883            (
1884                "block_private",
1885                VmValue::String(arcstr::ArcStr::from("private")),
1886            ),
1887            ("allow_loopback", VmValue::Bool(true)),
1888        ]);
1889        assert!(check_url("http_get", "http://127.0.0.1").unwrap().is_none());
1890        assert!(check_url("http_get", "https://[::1]").unwrap().is_none());
1891        // Metadata stays blocked even with the loopback hatch.
1892        assert!(check_url("http_get", "https://169.254.169.254")
1893            .unwrap()
1894            .is_some());
1895    }
1896
1897    #[test]
1898    fn default_on_blocks_loopback_under_run_scope() {
1899        let _guard = test_env_lock();
1900        reset_egress_policy_for_tests();
1901        {
1902            let _scope = require_ssrf_guard_for_host();
1903            // No policy configured at all, but the guard scope defaults to
1904            // BlockPrivate.
1905            assert!(check_url("http_get", "http://127.0.0.1").unwrap().is_some());
1906            assert!(check_url("http_get", "https://169.254.169.254")
1907                .unwrap()
1908                .is_some());
1909            // Public literal still allowed.
1910            assert!(check_url("http_get", "https://8.8.8.8").unwrap().is_none());
1911        }
1912        // Out of scope: allow-all restored.
1913        assert!(check_url("http_get", "http://127.0.0.1").unwrap().is_none());
1914        reset_egress_policy_for_tests();
1915    }
1916
1917    #[test]
1918    fn block_private_off_opts_out_under_run_scope() {
1919        let _guard = test_env_lock();
1920        reset_egress_policy_for_tests();
1921        install_test_policy(&[(
1922            "block_private",
1923            VmValue::String(arcstr::ArcStr::from("off")),
1924        )]);
1925        let _scope = require_ssrf_guard_for_host();
1926        // Explicit off overrides the default-on guard.
1927        assert!(check_url("http_get", "http://127.0.0.1").unwrap().is_none());
1928        reset_egress_policy_for_tests();
1929    }
1930
1931    #[test]
1932    fn env_allow_loopback_opts_out_under_run_scope() {
1933        let _guard = test_env_lock();
1934        reset_egress_policy_for_tests();
1935        std::env::set_var(HARN_EGRESS_ALLOW_LOOPBACK_ENV, "1");
1936        let _scope = require_ssrf_guard_for_host();
1937        // Loopback opened by env hatch...
1938        assert!(check_url("http_get", "http://127.0.0.1").unwrap().is_none());
1939        // ...but metadata stays blocked.
1940        assert!(check_url("http_get", "https://169.254.169.254")
1941            .unwrap()
1942            .is_some());
1943        std::env::remove_var(HARN_EGRESS_ALLOW_LOOPBACK_ENV);
1944        reset_egress_policy_for_tests();
1945    }
1946
1947    #[test]
1948    fn current_ssrf_client_settings_reflects_guard_scope() {
1949        let _guard = test_env_lock();
1950        reset_egress_policy_for_tests();
1951        assert_eq!(current_ssrf_client_settings(), (false, false));
1952        let _scope = require_ssrf_guard_for_host();
1953        assert_eq!(current_ssrf_client_settings(), (true, false));
1954        drop(_scope);
1955        reset_egress_policy_for_tests();
1956        assert_eq!(current_ssrf_client_settings(), (false, false));
1957    }
1958
1959    /// Accept exactly one TCP connection and answer a minimal HTTP/1.1 200 so a
1960    /// non-blocked client gets a real response. Returns the bound loopback port.
1961    fn spawn_one_shot_ok_server() -> (u16, std::thread::JoinHandle<()>) {
1962        use std::io::{Read, Write};
1963        let listener =
1964            std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback probe server");
1965        let port = listener.local_addr().expect("probe server addr").port();
1966        let handle = std::thread::spawn(move || {
1967            if let Ok((mut stream, _)) = listener.accept() {
1968                let mut buf = [0u8; 1024];
1969                let _ = stream.read(&mut buf);
1970                let _ = stream.write_all(
1971                    b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
1972                );
1973                let _ = stream.flush();
1974            }
1975        });
1976        (port, handle)
1977    }
1978
1979    /// Finding A: a client built through `install_ssrf_guard` under the run's
1980    /// SSRF scope must NOT be able to reach a hostname that resolves to a
1981    /// loopback/private address (the connect-time backstop drops it), while the
1982    /// same hostname stays reachable when the private block is off — proving the
1983    /// guard is actually installed and not over-blocking when disabled.
1984    #[tokio::test]
1985    async fn install_ssrf_guard_blocks_loopback_hostname_and_passes_when_off() {
1986        // No process-env mutation here: the guard scope and the block_private
1987        // override both go through thread-local test state, so no env lock is
1988        // needed (and holding a std mutex across `.await` would trip clippy).
1989
1990        // Under the guard scope, `localhost` resolves only to loopback, which the
1991        // backstop resolver drops → the send fails before any server bytes flow.
1992        let (port, server) = spawn_one_shot_ok_server();
1993        reset_egress_policy_for_tests();
1994        {
1995            let _scope = require_ssrf_guard_for_host();
1996            let client = install_ssrf_guard(reqwest::Client::builder())
1997                .build()
1998                .expect("guarded client builds");
1999            let result = client
2000                .get(format!("http://localhost:{port}/probe"))
2001                .send()
2002                .await;
2003            assert!(
2004                result.is_err(),
2005                "guarded client must not reach a loopback hostname, got {result:?}"
2006            );
2007        }
2008        // The listener never served anyone; release its accept thread.
2009        drop(server);
2010
2011        // With the private block explicitly off, the guard installs no resolver
2012        // and the very same hostname is reachable (no over-blocking).
2013        let (port, server) = spawn_one_shot_ok_server();
2014        reset_egress_policy_for_tests();
2015        install_test_policy(&[(
2016            "block_private",
2017            VmValue::String(arcstr::ArcStr::from("off")),
2018        )]);
2019        {
2020            let _scope = require_ssrf_guard_for_host();
2021            let client = install_ssrf_guard(reqwest::Client::builder())
2022                .build()
2023                .expect("unguarded client builds");
2024            let response = client
2025                .get(format!("http://localhost:{port}/probe"))
2026                .send()
2027                .await
2028                .expect("block_private:off permits loopback hostname");
2029            assert_eq!(response.status().as_u16(), 200);
2030        }
2031        server.join().expect("probe server thread");
2032        reset_egress_policy_for_tests();
2033    }
2034
2035    // --- NetPolicy CIDR/IP rules applied to RESOLVED host IPs (#3174). ---
2036    //
2037    // `evaluate_resolved_addrs` is the pure decision the async resolution path
2038    // and the connect-time GuardedResolver both rely on; testing it with
2039    // synthetic addresses pins the resolve-once-and-pin semantics without DNS.
2040
2041    /// Build a `ConfiguredPolicy` from `(key, value)` config pairs for the
2042    /// resolved-IP unit tests (no global state mutation, so no env lock needed).
2043    fn policy(config: &[(&str, VmValue)]) -> ConfiguredPolicy {
2044        let map = config
2045            .iter()
2046            .cloned()
2047            .map(|(key, value)| (key.to_string(), value))
2048            .collect();
2049        ConfiguredPolicy {
2050            source: "test",
2051            policy: policy_from_config(&map).expect("policy parses"),
2052        }
2053    }
2054
2055    fn ips(values: &[&str]) -> Vec<IpAddr> {
2056        values.iter().map(|v| v.parse().unwrap()).collect()
2057    }
2058
2059    fn target(url: &str) -> EgressTarget {
2060        EgressTarget::parse(url).expect("target parses")
2061    }
2062
2063    #[test]
2064    fn resolved_ip_in_deny_cidr_blocks_hostname() {
2065        // A deny CIDR must apply to a hostname that RESOLVES into it, not only
2066        // to URL-literal IPs. This is the core #3174 bypass.
2067        let pol = policy(&[("deny", strings(&["203.0.113.0/24"]))]);
2068        let blocked = evaluate_resolved_addrs(
2069            Some(&pol),
2070            "http_get",
2071            "https://evil.example.com",
2072            &target("https://evil.example.com"),
2073            &ips(&["203.0.113.7"]),
2074            false,
2075        )
2076        .expect("resolved address in deny CIDR is blocked");
2077        assert!(
2078            blocked.reason.contains("deny rule `203.0.113.0/24`"),
2079            "{}",
2080            blocked.reason
2081        );
2082        assert_eq!(blocked.host, "evil.example.com");
2083    }
2084
2085    #[test]
2086    fn resolved_ip_in_deny_ip_literal_blocks_hostname() {
2087        let pol = policy(&[("deny", strings(&["198.51.100.9"]))]);
2088        let blocked = evaluate_resolved_addrs(
2089            Some(&pol),
2090            "http_get",
2091            "https://name.example.com",
2092            &target("https://name.example.com"),
2093            &ips(&["198.51.100.9"]),
2094            false,
2095        )
2096        .expect("resolved address matching deny IP is blocked");
2097        assert!(
2098            blocked.reason.contains("deny rule `198.51.100.9`"),
2099            "{}",
2100            blocked.reason
2101        );
2102    }
2103
2104    #[test]
2105    fn resolved_ip_outside_deny_cidr_is_allowed() {
2106        let pol = policy(&[("deny", strings(&["203.0.113.0/24"]))]);
2107        assert!(evaluate_resolved_addrs(
2108            Some(&pol),
2109            "http_get",
2110            "https://ok.example.com",
2111            &target("https://ok.example.com"),
2112            &ips(&["198.51.100.7"]),
2113            false,
2114        )
2115        .is_none());
2116    }
2117
2118    #[test]
2119    fn resolved_ip_in_allow_cidr_permits_hostname_under_default_deny() {
2120        // default=deny + CIDR allowlist: a hostname resolving INTO the allow
2121        // CIDR must be permitted (resolution_required carries the deferred allow
2122        // decision from the sync layer).
2123        let pol = policy(&[
2124            ("allow", strings(&["10.0.0.0/8"])),
2125            ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
2126        ]);
2127        assert!(evaluate_resolved_addrs(
2128            Some(&pol),
2129            "http_get",
2130            "https://internal.example.com",
2131            &target("https://internal.example.com"),
2132            &ips(&["10.1.2.3"]),
2133            true,
2134        )
2135        .is_none());
2136    }
2137
2138    #[test]
2139    fn resolved_ip_outside_allow_cidr_blocks_hostname_under_default_deny() {
2140        let pol = policy(&[
2141            ("allow", strings(&["10.0.0.0/8"])),
2142            ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
2143        ]);
2144        let blocked = evaluate_resolved_addrs(
2145            Some(&pol),
2146            "http_get",
2147            "https://outside.example.com",
2148            &target("https://outside.example.com"),
2149            &ips(&["8.8.8.8"]),
2150            true,
2151        )
2152        .expect("resolved address outside allow CIDR is blocked under default deny");
2153        assert_eq!(blocked.reason, "no allow rule matched");
2154    }
2155
2156    #[test]
2157    fn deny_cidr_wins_over_allow_cidr_on_resolved_ip() {
2158        // Deny precedence: even if a resolved address is in an allow CIDR, a
2159        // matching deny CIDR blocks it.
2160        let pol = policy(&[
2161            ("allow", strings(&["10.0.0.0/8"])),
2162            ("deny", strings(&["10.6.0.0/16"])),
2163            ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
2164        ]);
2165        let blocked = evaluate_resolved_addrs(
2166            Some(&pol),
2167            "http_get",
2168            "https://internal.example.com",
2169            &target("https://internal.example.com"),
2170            &ips(&["10.6.1.2"]),
2171            true,
2172        )
2173        .expect("deny CIDR wins over allow CIDR");
2174        assert!(
2175            blocked.reason.contains("deny rule `10.6.0.0/16`"),
2176            "{}",
2177            blocked.reason
2178        );
2179    }
2180
2181    #[test]
2182    fn mixed_resolution_blocks_if_any_address_is_denied() {
2183        // A host answering with both an allowed and a denied address is blocked:
2184        // the denied address is a rebinding vector the backstop would pin to.
2185        let pol = policy(&[("deny", strings(&["203.0.113.0/24"]))]);
2186        assert!(evaluate_resolved_addrs(
2187            Some(&pol),
2188            "http_get",
2189            "https://mixed.example.com",
2190            &target("https://mixed.example.com"),
2191            &ips(&["8.8.8.8", "203.0.113.9"]),
2192            false,
2193        )
2194        .is_some());
2195    }
2196
2197    #[test]
2198    fn port_scoped_deny_cidr_only_matches_that_port_on_resolved_ip() {
2199        // `:443` qualifier is honored against the resolved address using the
2200        // request's destination port.
2201        let pol = policy(&[("deny", strings(&["203.0.113.0/24:443"]))]);
2202        // https → port 443 → blocked.
2203        assert!(evaluate_resolved_addrs(
2204            Some(&pol),
2205            "http_get",
2206            "https://p.example.com",
2207            &target("https://p.example.com"),
2208            &ips(&["203.0.113.7"]),
2209            false,
2210        )
2211        .is_some());
2212        // http → port 80 → not blocked by the :443 rule.
2213        assert!(evaluate_resolved_addrs(
2214            Some(&pol),
2215            "http_get",
2216            "http://p.example.com",
2217            &target("http://p.example.com"),
2218            &ips(&["203.0.113.7"]),
2219            false,
2220        )
2221        .is_none());
2222    }
2223
2224    #[test]
2225    fn ssrf_block_still_applies_to_resolved_ip() {
2226        // Even with no NetPolicy IP rules, block_private rejects a host that
2227        // resolves to a private address (existing behavior, kept).
2228        let pol = policy(&[(
2229            "block_private",
2230            VmValue::String(arcstr::ArcStr::from("private")),
2231        )]);
2232        let blocked = evaluate_resolved_addrs(
2233            Some(&pol),
2234            "http_get",
2235            "https://rebind.example.com",
2236            &target("https://rebind.example.com"),
2237            &ips(&["10.0.0.1"]),
2238            false,
2239        )
2240        .expect("private resolved address blocked");
2241        assert!(
2242            blocked.reason.contains("disallowed address"),
2243            "{}",
2244            blocked.reason
2245        );
2246    }
2247
2248    #[test]
2249    fn check_url_defers_hostname_with_cidr_allowlist_under_default_deny() {
2250        // The sync layer must NOT block a hostname outright when a CIDR allow
2251        // rule could still grant it after resolution.
2252        let _guard = install(&[
2253            ("allow", strings(&["10.0.0.0/8"])),
2254            ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
2255        ]);
2256        match check_url_decision("http_get", "https://internal.example.com").unwrap() {
2257            SyncCheck::DeferToResolution(_) => {}
2258            other => panic!(
2259                "expected DeferToResolution, got {}",
2260                match other {
2261                    SyncCheck::Allowed => "Allowed",
2262                    SyncCheck::Blocked(_) => "Blocked",
2263                    SyncCheck::DeferToResolution(_) => unreachable!(),
2264                }
2265            ),
2266        }
2267        // A literal IP outside the allow CIDR is still blocked outright (no
2268        // resolution can change a literal).
2269        assert!(matches!(
2270            check_url_decision("http_get", "https://8.8.8.8").unwrap(),
2271            SyncCheck::Blocked(_)
2272        ));
2273        // With only host/suffix allow rules, an unknown host is blocked outright.
2274        drop(_guard);
2275        let _guard = install(&[
2276            ("allow", strings(&["api.example.com"])),
2277            ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
2278        ]);
2279        assert!(matches!(
2280            check_url_decision("http_get", "https://other.example.com").unwrap(),
2281            SyncCheck::Blocked(_)
2282        ));
2283    }
2284
2285    #[test]
2286    fn resolved_ip_rules_for_drops_host_suffix_and_port_scoped_rules() {
2287        // The connect-time backstop carries only port-unscoped IP/CIDR rules:
2288        // host/suffix rules pin to the hostname, and port-scoped rules can't be
2289        // honored by the resolver (it sees no destination port).
2290        let pol = policy(&[
2291            (
2292                "deny",
2293                strings(&[
2294                    "203.0.113.0/24",
2295                    "198.51.100.5",
2296                    "*.evil.com",
2297                    "10.0.0.0/8:443",
2298                ]),
2299            ),
2300            ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
2301        ]);
2302        let rules = resolved_ip_rules_for(&pol.policy);
2303        assert_eq!(
2304            rules.deny.len(),
2305            2,
2306            "host/suffix + port-scoped rules dropped"
2307        );
2308        assert!(rules.deny.contains(&"203.0.113.0/24".parse().unwrap()));
2309        assert!(rules.deny.contains(&"198.51.100.5/32".parse().unwrap()));
2310        assert!(rules.allow_active);
2311    }
2312
2313    #[test]
2314    fn current_resolved_ip_rules_reflects_installed_policy() {
2315        let _guard = install(&[("deny", strings(&["203.0.113.0/24"]))]);
2316        let rules = current_resolved_ip_rules();
2317        assert_eq!(rules.deny, vec!["203.0.113.0/24".parse().unwrap()]);
2318    }
2319}