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#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
41pub enum SsrfMode {
42 Off,
44 #[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 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 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 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#[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#[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
335pub 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
357pub 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
390pub fn current_ssrf_client_settings() -> (bool, bool) {
398 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
428pub struct ResolvedIpRules {
429 pub deny: Vec<IpNet>,
431 pub allow: Vec<IpNet>,
434 pub allow_active: bool,
437}
438
439impl ResolvedIpRules {
440 pub fn is_empty(&self) -> bool {
443 self.deny.is_empty() && !self.allow_active
444 }
445}
446
447fn 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
456fn 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
497pub 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
528pub fn install_ssrf_guard(builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
546 install_ssrf_guard_with_private_host_allowlist(builder, &[])
547}
548
549fn 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
574fn 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
583fn 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
594async 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
612enum SyncCheck {
614 Allowed,
617 Blocked(EgressBlocked),
619 DeferToResolution(EgressBlocked),
624}
625
626fn 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 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 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 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
739async 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 let has_ip_deny = configured
782 .as_ref()
783 .is_some_and(|c| c.policy.deny.iter().any(EgressRule::is_ip_matcher));
784
785 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 return Ok(None);
796 }
797
798 let Some(addrs) = resolve_host_addrs(&target.host).await else {
799 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
823fn 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 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 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 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" | "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 fn is_ip_matcher(&self) -> bool {
1262 matches!(self.matcher, EgressMatcher::Ip(_) | EgressMatcher::Cidr(_))
1263 }
1264
1265 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 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 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 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 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 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 assert!(check_url("http_get", "https://8.8.8.8").unwrap().is_none());
1906 }
1907 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 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 assert!(check_url("http_get", "http://127.0.0.1").unwrap().is_none());
1934 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 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 #[tokio::test]
1980 async fn install_ssrf_guard_blocks_loopback_hostname_and_passes_when_off() {
1981 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 drop(server);
2005
2006 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 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 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 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 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 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 let pol = policy(&[("deny", strings(&["203.0.113.0/24:443"]))]);
2197 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 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 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 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 assert!(matches!(
2265 check_url_decision("http_get", "https://8.8.8.8").unwrap(),
2266 SyncCheck::Blocked(_)
2267 ));
2268 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 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}