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