1use crate::value::VmDictExt;
2use std::cell::RefCell;
3use std::collections::BTreeMap;
4#[cfg(test)]
5use std::collections::{HashMap, HashSet};
6use std::net::IpAddr;
7use std::str::FromStr;
8use std::sync::{OnceLock, RwLock};
9
10use ipnet::IpNet;
11use serde_json::json;
12use url::Url;
13
14use crate::event_log::{active_event_log, EventLog, LogEvent, Topic};
15use crate::value::{VmError, VmValue};
16use crate::vm::Vm;
17
18pub mod ssrf;
19
20pub use ssrf::{is_disallowed_ip, GuardedResolver};
21
22pub const HARN_EGRESS_ALLOW_ENV: &str = "HARN_EGRESS_ALLOW";
23pub const HARN_EGRESS_DENY_ENV: &str = "HARN_EGRESS_DENY";
24pub const HARN_EGRESS_DEFAULT_ENV: &str = "HARN_EGRESS_DEFAULT";
25pub const HARN_EGRESS_BLOCK_PRIVATE_ENV: &str = "HARN_EGRESS_BLOCK_PRIVATE";
26pub const HARN_EGRESS_ALLOW_LOOPBACK_ENV: &str = "HARN_EGRESS_ALLOW_LOOPBACK";
27pub const EGRESS_AUDIT_TOPIC: &str = "connectors.egress.audit";
28
29thread_local! {
30 static REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH: RefCell<usize> = const { RefCell::new(0) };
31 static REQUIRE_SSRF_GUARD_DEPTH: RefCell<usize> = const { RefCell::new(0) };
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
36pub enum SsrfMode {
37 Off,
39 #[default]
42 BlockPrivate,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46enum DefaultAction {
47 Allow,
48 Deny,
49}
50
51#[derive(Clone, Debug)]
52struct EgressPolicy {
53 allow: Vec<EgressRule>,
54 deny: Vec<EgressRule>,
55 default: DefaultAction,
56 block_private: Option<SsrfMode>,
60 allow_loopback: bool,
61}
62
63#[derive(Clone, Debug)]
64struct EgressRule {
65 raw: String,
66 matcher: EgressMatcher,
67 port: Option<u16>,
68}
69
70#[derive(Clone, Debug)]
71enum EgressMatcher {
72 Host(String),
73 Suffix(String),
74 Ip(IpAddr),
75 Cidr(IpNet),
76}
77
78#[derive(Clone, Debug)]
79struct EgressState {
80 #[cfg(not(test))]
81 env_checked: bool,
82 #[cfg(not(test))]
83 policy: Option<ConfiguredPolicy>,
84 #[cfg(test)]
85 test_env_checked: HashSet<std::thread::ThreadId>,
86 #[cfg(test)]
87 test_policies: HashMap<std::thread::ThreadId, ConfiguredPolicy>,
88}
89
90#[derive(Clone, Debug)]
91struct ConfiguredPolicy {
92 source: &'static str,
93 policy: EgressPolicy,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub struct EgressBlocked {
98 pub surface: String,
99 pub url: String,
100 pub host: String,
101 pub port: Option<u16>,
102 pub reason: String,
103}
104
105static EGRESS_STATE: OnceLock<RwLock<EgressState>> = OnceLock::new();
106
107fn state() -> &'static RwLock<EgressState> {
108 EGRESS_STATE.get_or_init(|| {
109 RwLock::new(EgressState {
110 #[cfg(not(test))]
111 env_checked: false,
112 #[cfg(not(test))]
113 policy: None,
114 #[cfg(test)]
115 test_env_checked: HashSet::new(),
116 #[cfg(test)]
117 test_policies: HashMap::new(),
118 })
119 })
120}
121
122pub fn register_egress_builtins(vm: &mut Vm) {
123 vm.register_builtin("egress_policy", |args, _out| {
124 let Some(VmValue::Dict(config)) = args.first() else {
125 return Err(vm_error("egress_policy: requires a config dict"));
126 };
127 let policy = policy_from_config(config)?;
128 install_policy(policy, "stdlib")?;
129 Ok(policy_summary())
130 });
131}
132
133pub async fn enforce_url_allowed(surface: &str, url: &str) -> Result<(), VmError> {
134 let resolution_required = match check_url_decision(surface, url)? {
139 SyncCheck::Allowed => false,
140 SyncCheck::DeferToResolution(_) => true,
141 SyncCheck::Blocked(blocked) => {
142 audit_blocked(&blocked).await;
143 return Err(blocked.to_vm_error());
144 }
145 };
146 if let Some(blocked) = check_url_host_resolution(surface, url, resolution_required).await? {
152 audit_blocked(&blocked).await;
153 return Err(blocked.to_vm_error());
154 }
155 Ok(())
156}
157
158pub fn redirect_url_allowed(surface: &str, previous_url: Option<&str>, url: &str) -> bool {
159 match check_redirect_url(surface, previous_url, url) {
160 Ok(Some(blocked)) => {
161 audit_blocked_background(blocked);
162 false
163 }
164 Ok(None) => true,
165 Err(_) => false,
166 }
167}
168
169pub fn redirect_policy(surface: &'static str, max_redirects: usize) -> reqwest::redirect::Policy {
170 reqwest::redirect::Policy::custom(move |attempt| {
171 if attempt.previous().len() >= max_redirects {
172 attempt.error("too many redirects")
173 } else if redirect_url_allowed(
174 surface,
175 attempt.previous().last().map(|url| url.as_str()),
176 attempt.url().as_str(),
177 ) {
178 attempt.follow()
179 } else {
180 attempt.error("egress policy blocked redirect target")
181 }
182 })
183}
184
185pub fn redact_reqwest_error(error: &reqwest::Error) -> String {
186 redact_diagnostic_text(&error.to_string())
187}
188
189pub fn redact_diagnostic_text(raw: &str) -> String {
190 let policy = crate::redact::current_policy();
191 let urls_redacted = policy.redact_urls_in_text(raw);
192 policy.redact_string(urls_redacted.as_ref()).into_owned()
193}
194
195fn check_redirect_url(
196 surface: &str,
197 previous_url: Option<&str>,
198 raw_url: &str,
199) -> Result<Option<EgressBlocked>, VmError> {
200 if let Some(blocked) = insecure_redirect_downgrade_block(surface, previous_url, raw_url)? {
201 return Ok(Some(blocked));
202 }
203 check_url(surface, raw_url)
204}
205
206fn insecure_redirect_downgrade_block(
207 surface: &str,
208 previous_url: Option<&str>,
209 raw_url: &str,
210) -> Result<Option<EgressBlocked>, VmError> {
211 let Some(previous_url) = previous_url else {
212 return Ok(None);
213 };
214 let previous = Url::parse(previous_url).map_err(|error| {
215 vm_error(format!(
216 "egress: invalid redirect source `{previous_url}`: {error}"
217 ))
218 })?;
219 if previous.scheme() != "https" {
220 return Ok(None);
221 }
222 let target_url = Url::parse(raw_url)
223 .map_err(|error| vm_error(format!("egress: invalid URL `{raw_url}`: {error}")))?;
224 if target_url.scheme() != "http" {
225 return Ok(None);
226 }
227
228 let target = EgressTarget::parse(raw_url)?;
229 if insecure_redirect_loopback_exempt(&target) {
230 return Ok(None);
231 }
232 Ok(Some(blocked(
233 surface,
234 raw_url,
235 &target,
236 "https redirect target downgrades to insecure http".to_string(),
237 )))
238}
239
240fn insecure_redirect_loopback_exempt(target: &EgressTarget) -> bool {
241 let (_, allow_loopback) = current_ssrf_client_settings();
242 allow_loopback && target.is_loopback_host()
243}
244
245pub fn client_error_for_url(surface: &str, url: &str) -> Option<crate::connectors::ClientError> {
246 match check_url(surface, url) {
247 Ok(Some(blocked)) => {
248 audit_blocked_background(blocked.clone());
249 Some(crate::connectors::ClientError::EgressBlocked(blocked))
250 }
251 Ok(None) => None,
252 Err(error) => Some(crate::connectors::ClientError::InvalidArgs(
253 error.to_string(),
254 )),
255 }
256}
257
258pub fn connector_error_for_url(
259 surface: &str,
260 url: &str,
261) -> Option<crate::connectors::ConnectorError> {
262 match check_url(surface, url) {
263 Ok(Some(blocked)) => {
264 audit_blocked_background(blocked.clone());
265 Some(crate::connectors::ConnectorError::Activation(
266 blocked.to_string(),
267 ))
268 }
269 Ok(None) => None,
270 Err(error) => Some(crate::connectors::ConnectorError::Activation(
271 error.to_string(),
272 )),
273 }
274}
275
276pub fn reset_egress_policy_for_host() {
277 let mut guard = state().write().expect("egress policy state poisoned");
278 #[cfg(test)]
279 {
280 let thread_id = std::thread::current().id();
281 guard.test_env_checked.remove(&thread_id);
282 guard.test_policies.remove(&thread_id);
283 }
284 #[cfg(not(test))]
285 {
286 guard.env_checked = false;
287 guard.policy = None;
288 }
289 clear_explicit_egress_policy_requirement_for_host();
290 clear_ssrf_guard_requirement_for_host();
291}
292
293pub(crate) fn clear_explicit_egress_policy_requirement_for_host() {
294 REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH.with(|depth| *depth.borrow_mut() = 0);
295}
296
297#[cfg(test)]
298pub fn reset_egress_policy_for_tests() {
299 reset_egress_policy_for_host();
300}
301
302#[cfg(test)]
308pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
309 use std::sync::{Mutex, OnceLock};
310 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
311 LOCK.get_or_init(|| Mutex::new(()))
312 .lock()
313 .unwrap_or_else(|poisoned| poisoned.into_inner())
314}
315
316#[cfg(test)]
320pub(crate) fn install_test_policy(config: &[(&str, VmValue)]) {
321 let map = config
322 .iter()
323 .cloned()
324 .map(|(key, value)| (key.to_string(), value))
325 .collect();
326 let policy = policy_from_config(&map).expect("test egress policy parses");
327 install_policy(policy, "test").expect("test egress policy installs");
328}
329
330pub fn require_explicit_egress_policy_for_host() -> ExplicitEgressPolicyGuard {
334 REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH.with(|depth| {
335 *depth.borrow_mut() += 1;
336 });
337 ExplicitEgressPolicyGuard
338}
339
340#[derive(Debug)]
341pub struct ExplicitEgressPolicyGuard;
342
343impl Drop for ExplicitEgressPolicyGuard {
344 fn drop(&mut self) {
345 REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH.with(|depth| {
346 let mut depth = depth.borrow_mut();
347 *depth = depth.saturating_sub(1);
348 });
349 }
350}
351
352pub fn require_ssrf_guard_for_host() -> SsrfGuardScope {
359 REQUIRE_SSRF_GUARD_DEPTH.with(|depth| {
360 *depth.borrow_mut() += 1;
361 });
362 SsrfGuardScope
363}
364
365#[derive(Debug)]
366pub struct SsrfGuardScope;
367
368impl Drop for SsrfGuardScope {
369 fn drop(&mut self) {
370 REQUIRE_SSRF_GUARD_DEPTH.with(|depth| {
371 let mut depth = depth.borrow_mut();
372 *depth = depth.saturating_sub(1);
373 });
374 }
375}
376
377pub(crate) fn clear_ssrf_guard_requirement_for_host() {
378 REQUIRE_SSRF_GUARD_DEPTH.with(|depth| *depth.borrow_mut() = 0);
379}
380
381fn ssrf_guard_scope_active() -> bool {
382 REQUIRE_SSRF_GUARD_DEPTH.with(|depth| *depth.borrow() > 0)
383}
384
385pub fn current_ssrf_client_settings() -> (bool, bool) {
393 let _ = ensure_env_seeded();
395 let configured = {
396 let guard = state().read().expect("egress policy state poisoned");
397 #[cfg(test)]
398 {
399 guard
400 .test_policies
401 .get(&std::thread::current().id())
402 .cloned()
403 }
404 #[cfg(not(test))]
405 {
406 guard.policy.clone()
407 }
408 };
409 let (mode, allow_loopback) = effective_ssrf_settings(configured.as_ref().map(|c| &c.policy));
410 (mode == SsrfMode::BlockPrivate, allow_loopback)
411}
412
413#[derive(Clone, Debug, Default, PartialEq, Eq)]
423pub struct ResolvedIpRules {
424 pub deny: Vec<IpNet>,
426 pub allow: Vec<IpNet>,
429 pub allow_active: bool,
432}
433
434impl ResolvedIpRules {
435 pub fn is_empty(&self) -> bool {
438 self.deny.is_empty() && !self.allow_active
439 }
440}
441
442fn ip_to_net(ip: IpAddr) -> IpNet {
445 match ip {
446 IpAddr::V4(v4) => IpNet::from(ipnet::Ipv4Net::new(v4, 32).expect("/32 is valid")),
447 IpAddr::V6(v6) => IpNet::from(ipnet::Ipv6Net::new(v6, 128).expect("/128 is valid")),
448 }
449}
450
451fn resolved_ip_rules_for(policy: &EgressPolicy) -> ResolvedIpRules {
468 let net_of = |rule: &EgressRule| match &rule.matcher {
469 EgressMatcher::Cidr(net) => Some(*net),
470 EgressMatcher::Ip(ip) => Some(ip_to_net(*ip)),
471 _ => None,
472 };
473 let deny: Vec<IpNet> = policy
474 .deny
475 .iter()
476 .filter(|rule| rule.port.is_none())
477 .filter_map(net_of)
478 .collect();
479 let allow: Vec<IpNet> = policy
480 .allow
481 .iter()
482 .filter(|rule| rule.port.is_none())
483 .filter_map(net_of)
484 .collect();
485 ResolvedIpRules {
486 deny,
487 allow,
488 allow_active: policy.default == DefaultAction::Deny,
489 }
490}
491
492pub fn current_resolved_ip_rules() -> ResolvedIpRules {
502 let _ = ensure_env_seeded();
503 let configured = {
504 let guard = state().read().expect("egress policy state poisoned");
505 #[cfg(test)]
506 {
507 guard
508 .test_policies
509 .get(&std::thread::current().id())
510 .cloned()
511 }
512 #[cfg(not(test))]
513 {
514 guard.policy.clone()
515 }
516 };
517 configured
518 .as_ref()
519 .map(|c| resolved_ip_rules_for(&c.policy))
520 .unwrap_or_default()
521}
522
523pub fn install_ssrf_guard(builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
541 let (block_private, allow_loopback) = current_ssrf_client_settings();
542 let rules = current_resolved_ip_rules();
543 if block_private || !rules.deny.is_empty() {
544 builder.dns_resolver(std::sync::Arc::new(GuardedResolver::with_policy(
545 block_private,
546 allow_loopback,
547 &rules,
548 )))
549 } else {
550 builder
551 }
552}
553
554fn effective_ssrf_settings(configured: Option<&EgressPolicy>) -> (SsrfMode, bool) {
558 let scope_active = ssrf_guard_scope_active();
559 match configured {
560 Some(policy) => {
561 let mode = policy.block_private.unwrap_or(if scope_active {
562 SsrfMode::BlockPrivate
563 } else {
564 SsrfMode::Off
565 });
566 (mode, policy.allow_loopback)
567 }
568 None => {
569 let mode = if scope_active {
570 SsrfMode::BlockPrivate
571 } else {
572 SsrfMode::Off
573 };
574 (mode, false)
575 }
576 }
577}
578
579fn private_block_reason(host: &str) -> String {
582 format!(
583 "host `{host}` is a disallowed address \
584 (private, loopback, link-local, or metadata IP)"
585 )
586}
587
588fn private_block_for_literal(target: &EgressTarget, allow_loopback: bool) -> Option<String> {
594 target.ip.and_then(|ip| {
595 is_disallowed_ip(ip, allow_loopback).then(|| private_block_reason(&target.host))
596 })
597}
598
599async fn resolve_host_addrs(host: &str) -> Option<Vec<IpAddr>> {
603 use std::net::ToSocketAddrs;
604 let host = host.to_string();
605 tokio::task::spawn_blocking(move || {
606 (host.as_str(), 0_u16)
607 .to_socket_addrs()
608 .ok()
609 .map(|addrs| addrs.map(|addr| addr.ip()).collect::<Vec<_>>())
610 .filter(|addrs: &Vec<IpAddr>| !addrs.is_empty())
611 })
612 .await
613 .ok()
614 .flatten()
615}
616
617enum SyncCheck {
619 Allowed,
622 Blocked(EgressBlocked),
624 DeferToResolution(EgressBlocked),
629}
630
631fn check_url(surface: &str, raw_url: &str) -> Result<Option<EgressBlocked>, VmError> {
636 match check_url_decision(surface, raw_url)? {
637 SyncCheck::Allowed => Ok(None),
638 SyncCheck::Blocked(blocked) | SyncCheck::DeferToResolution(blocked) => Ok(Some(blocked)),
639 }
640}
641
642fn check_url_decision(surface: &str, raw_url: &str) -> Result<SyncCheck, VmError> {
643 ensure_env_seeded()?;
644 let configured = {
645 let guard = state().read().expect("egress policy state poisoned");
646 #[cfg(test)]
647 {
648 let thread_id = std::thread::current().id();
649 guard.test_policies.get(&thread_id).cloned()
650 }
651 #[cfg(not(test))]
652 {
653 guard.policy.clone()
654 }
655 };
656 let (ssrf_mode, allow_loopback) =
657 effective_ssrf_settings(configured.as_ref().map(|c| &c.policy));
658 let require_explicit_policy =
659 REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH.with(|depth| *depth.borrow() > 0);
660
661 let Some(configured) = configured else {
662 if ssrf_mode == SsrfMode::BlockPrivate {
665 let target = EgressTarget::parse(raw_url)?;
666 if let Some(reason) = private_block_for_literal(&target, allow_loopback) {
667 return Ok(SyncCheck::Blocked(blocked(
668 surface, raw_url, &target, reason,
669 )));
670 }
671 }
672 if require_explicit_policy {
673 let target = EgressTarget::parse(raw_url)?;
674 return Ok(SyncCheck::Blocked(blocked(
675 surface,
676 raw_url,
677 &target,
678 "no egress policy configured".to_string(),
679 )));
680 }
681 return Ok(SyncCheck::Allowed);
682 };
683 let target = EgressTarget::parse(raw_url)?;
684
685 if ssrf_mode == SsrfMode::BlockPrivate {
690 if let Some(reason) = private_block_for_literal(&target, allow_loopback) {
691 return Ok(SyncCheck::Blocked(blocked(
692 surface, raw_url, &target, reason,
693 )));
694 }
695 }
696
697 if let Some(rule) = configured
698 .policy
699 .deny
700 .iter()
701 .find(|rule| rule.matches(&target))
702 {
703 return Ok(SyncCheck::Blocked(blocked(
704 surface,
705 raw_url,
706 &target,
707 format!("matched deny rule `{}`", rule.raw),
708 )));
709 }
710 if configured
711 .policy
712 .allow
713 .iter()
714 .any(|rule| rule.matches(&target))
715 {
716 return Ok(SyncCheck::Allowed);
717 }
718 if configured.policy.default == DefaultAction::Allow {
719 return Ok(SyncCheck::Allowed);
720 }
721
722 let could_allow_on_resolution = target.ip.is_none()
726 && configured
727 .policy
728 .allow
729 .iter()
730 .any(EgressRule::is_ip_matcher);
731 let blocked = blocked(
732 surface,
733 raw_url,
734 &target,
735 "no allow rule matched".to_string(),
736 );
737 if could_allow_on_resolution {
738 Ok(SyncCheck::DeferToResolution(blocked))
739 } else {
740 Ok(SyncCheck::Blocked(blocked))
741 }
742}
743
744async fn check_url_host_resolution(
765 surface: &str,
766 raw_url: &str,
767 resolution_required: bool,
768) -> Result<Option<EgressBlocked>, VmError> {
769 let configured = {
770 let guard = state().read().expect("egress policy state poisoned");
771 #[cfg(test)]
772 {
773 let thread_id = std::thread::current().id();
774 guard.test_policies.get(&thread_id).cloned()
775 }
776 #[cfg(not(test))]
777 {
778 guard.policy.clone()
779 }
780 };
781 let (ssrf_mode, _allow_loopback) =
782 effective_ssrf_settings(configured.as_ref().map(|c| &c.policy));
783 let block_private = ssrf_mode == SsrfMode::BlockPrivate;
784 let has_ip_deny = configured
787 .as_ref()
788 .is_some_and(|c| c.policy.deny.iter().any(EgressRule::is_ip_matcher));
789
790 if !block_private && !has_ip_deny && !resolution_required {
793 return Ok(None);
794 }
795
796 let target = EgressTarget::parse(raw_url)?;
797 if target.ip.is_some() {
798 return Ok(None);
801 }
802
803 let Some(addrs) = resolve_host_addrs(&target.host).await else {
804 if resolution_required {
808 return Ok(Some(blocked(
809 surface,
810 raw_url,
811 &target,
812 "no allow rule matched".to_string(),
813 )));
814 }
815 return Ok(None);
816 };
817
818 Ok(evaluate_resolved_addrs(
819 configured.as_ref(),
820 surface,
821 raw_url,
822 &target,
823 &addrs,
824 resolution_required,
825 ))
826}
827
828fn evaluate_resolved_addrs(
838 configured: Option<&ConfiguredPolicy>,
839 surface: &str,
840 raw_url: &str,
841 target: &EgressTarget,
842 addrs: &[IpAddr],
843 resolution_required: bool,
844) -> Option<EgressBlocked> {
845 let (ssrf_mode, allow_loopback) = effective_ssrf_settings(configured.map(|c| &c.policy));
846 let block_private = ssrf_mode == SsrfMode::BlockPrivate;
847 let allow_active = configured.is_some_and(|c| c.policy.default == DefaultAction::Deny);
848
849 if block_private && addrs.iter().any(|ip| is_disallowed_ip(*ip, allow_loopback)) {
852 return Some(blocked(
853 surface,
854 raw_url,
855 target,
856 private_block_reason(&target.host),
857 ));
858 }
859
860 if let Some(rule) = configured.and_then(|c| {
866 c.policy.deny.iter().find(|rule| {
867 rule.is_ip_matcher()
868 && addrs
869 .iter()
870 .any(|ip| rule.matches_resolved_ip(*ip, target.port))
871 })
872 }) {
873 return Some(blocked(
874 surface,
875 raw_url,
876 target,
877 format!("resolved address matched deny rule `{}`", rule.raw),
878 ));
879 }
880
881 if resolution_required && allow_active {
886 let permitted = configured.is_some_and(|c| {
887 c.policy.allow.iter().any(|rule| {
888 rule.is_ip_matcher()
889 && addrs
890 .iter()
891 .any(|ip| rule.matches_resolved_ip(*ip, target.port))
892 })
893 });
894 if !permitted {
895 return Some(blocked(
896 surface,
897 raw_url,
898 target,
899 "no allow rule matched".to_string(),
900 ));
901 }
902 }
903
904 None
905}
906
907fn blocked(surface: &str, url: &str, target: &EgressTarget, reason: String) -> EgressBlocked {
908 EgressBlocked {
909 surface: surface.to_string(),
910 url: redact_sensitive_url(url),
911 host: target.host.clone(),
912 port: target.port,
913 reason,
914 }
915}
916
917async fn audit_blocked(blocked: &EgressBlocked) {
918 let Some(log) = active_event_log() else {
919 return;
920 };
921 let Ok(topic) = Topic::new(EGRESS_AUDIT_TOPIC) else {
922 return;
923 };
924 let payload = json!({
925 "surface": blocked.surface,
926 "url": blocked.url,
927 "host": blocked.host,
928 "port": blocked.port,
929 "reason": blocked.reason,
930 "error_type": "EgressBlocked",
931 });
932 let _ = log
933 .append(&topic, LogEvent::new("egress.blocked", payload))
934 .await;
935}
936
937fn audit_blocked_background(blocked: EgressBlocked) {
938 let Some(log) = active_event_log() else {
939 return;
940 };
941 let Ok(topic) = Topic::new(EGRESS_AUDIT_TOPIC) else {
942 return;
943 };
944 if let Ok(handle) = tokio::runtime::Handle::try_current() {
945 handle.spawn(async move {
946 let payload = json!({
947 "surface": blocked.surface,
948 "url": blocked.url,
949 "host": blocked.host,
950 "port": blocked.port,
951 "reason": blocked.reason,
952 "error_type": "EgressBlocked",
953 });
954 let _ = log
955 .append(&topic, LogEvent::new("egress.blocked", payload))
956 .await;
957 });
958 }
959}
960
961fn install_policy(policy: EgressPolicy, source: &'static str) -> Result<(), VmError> {
962 ensure_env_seeded()?;
963 let mut guard = state().write().expect("egress policy state poisoned");
964 #[cfg(test)]
965 {
966 let thread_id = std::thread::current().id();
967 if let Some(existing) = guard.test_policies.get(&thread_id) {
968 return Err(vm_error(format!(
969 "egress_policy: policy already configured from {}",
970 existing.source
971 )));
972 }
973 guard
974 .test_policies
975 .insert(thread_id, ConfiguredPolicy { source, policy });
976 Ok(())
977 }
978 #[cfg(not(test))]
979 {
980 if let Some(existing) = &guard.policy {
981 return Err(vm_error(format!(
982 "egress_policy: policy already configured from {}",
983 existing.source
984 )));
985 }
986 guard.policy = Some(ConfiguredPolicy { source, policy });
987 Ok(())
988 }
989}
990
991fn ensure_env_seeded() -> Result<(), VmError> {
992 {
993 let guard = state().read().expect("egress policy state poisoned");
994 #[cfg(test)]
995 {
996 if guard
997 .test_env_checked
998 .contains(&std::thread::current().id())
999 {
1000 return Ok(());
1001 }
1002 }
1003 #[cfg(not(test))]
1004 {
1005 if guard.env_checked {
1006 return Ok(());
1007 }
1008 }
1009 }
1010
1011 let allow = std::env::var(HARN_EGRESS_ALLOW_ENV).ok();
1012 let deny = std::env::var(HARN_EGRESS_DENY_ENV).ok();
1013 let default = std::env::var(HARN_EGRESS_DEFAULT_ENV).ok();
1014 let block_private = std::env::var(HARN_EGRESS_BLOCK_PRIVATE_ENV).ok();
1015 let allow_loopback = std::env::var(HARN_EGRESS_ALLOW_LOOPBACK_ENV).ok();
1016 let any_set = allow.is_some()
1017 || deny.is_some()
1018 || default.is_some()
1019 || block_private.is_some()
1020 || allow_loopback.is_some();
1021 let build_policy = || -> Result<EgressPolicy, VmError> {
1022 Ok(EgressPolicy {
1023 allow: parse_rule_list(allow.as_deref().unwrap_or(""))?,
1024 deny: parse_rule_list(deny.as_deref().unwrap_or(""))?,
1025 default: parse_default_action(default.as_deref().unwrap_or("allow"))?,
1026 block_private: block_private.as_deref().map(parse_ssrf_mode).transpose()?,
1027 allow_loopback: allow_loopback
1028 .as_deref()
1029 .map(parse_bool)
1030 .transpose()?
1031 .unwrap_or(false),
1032 })
1033 };
1034 let mut guard = state().write().expect("egress policy state poisoned");
1035 #[cfg(test)]
1036 {
1037 let thread_id = std::thread::current().id();
1038 if guard.test_env_checked.contains(&thread_id) {
1039 return Ok(());
1040 }
1041 guard.test_env_checked.insert(thread_id);
1042 if !any_set {
1043 return Ok(());
1044 }
1045 guard.test_policies.insert(
1046 thread_id,
1047 ConfiguredPolicy {
1048 source: "environment",
1049 policy: build_policy()?,
1050 },
1051 );
1052 Ok(())
1053 }
1054 #[cfg(not(test))]
1055 {
1056 if guard.env_checked {
1057 return Ok(());
1058 }
1059 guard.env_checked = true;
1060 if !any_set {
1061 return Ok(());
1062 }
1063 guard.policy = Some(ConfiguredPolicy {
1064 source: "environment",
1065 policy: build_policy()?,
1066 });
1067 Ok(())
1068 }
1069}
1070
1071fn policy_from_config(config: &crate::value::DictMap) -> Result<EgressPolicy, VmError> {
1072 let allow = match config.get("allow") {
1073 Some(VmValue::List(items)) => parse_rule_values(items)?,
1074 Some(VmValue::Nil) => Vec::new(),
1075 Some(_) => return Err(vm_error("egress_policy: allow must be a list")),
1076 None => Vec::new(),
1077 };
1078 let deny = match config.get("deny") {
1079 Some(VmValue::List(items)) => parse_rule_values(items)?,
1080 Some(VmValue::Nil) => Vec::new(),
1081 Some(_) => return Err(vm_error("egress_policy: deny must be a list")),
1082 None => Vec::new(),
1083 };
1084 let default = config
1085 .get("default")
1086 .map(|value| parse_default_action(&value.display()))
1087 .transpose()?
1088 .unwrap_or(DefaultAction::Allow);
1089 let block_private = config
1090 .get("block_private")
1091 .map(|value| parse_ssrf_mode(&value.display()))
1092 .transpose()?;
1093 let allow_loopback = match config.get("allow_loopback") {
1094 Some(value) => parse_bool(&value.display())?,
1095 None => false,
1096 };
1097 Ok(EgressPolicy {
1098 allow,
1099 deny,
1100 default,
1101 block_private,
1102 allow_loopback,
1103 })
1104}
1105
1106fn parse_ssrf_mode(raw: &str) -> Result<SsrfMode, VmError> {
1107 match raw.trim().to_ascii_lowercase().as_str() {
1108 "private" | "on" | "block" | "block_private" | "true" => Ok(SsrfMode::BlockPrivate),
1110 "off" | "false" | "none" => Ok(SsrfMode::Off),
1111 other => Err(vm_error(format!(
1112 "egress_policy: block_private must be `private`/`on` or `off`, got `{other}`"
1113 ))),
1114 }
1115}
1116
1117fn parse_bool(raw: &str) -> Result<bool, VmError> {
1118 match raw.trim().to_ascii_lowercase().as_str() {
1119 "true" | "1" | "yes" | "on" => Ok(true),
1120 "false" | "0" | "no" | "off" | "" => Ok(false),
1121 other => Err(vm_error(format!(
1122 "egress_policy: allow_loopback must be a boolean, got `{other}`"
1123 ))),
1124 }
1125}
1126
1127fn parse_rule_values(values: &[VmValue]) -> Result<Vec<EgressRule>, VmError> {
1128 values
1129 .iter()
1130 .map(|value| EgressRule::parse(&value.display()))
1131 .collect()
1132}
1133
1134fn parse_rule_list(raw: &str) -> Result<Vec<EgressRule>, VmError> {
1135 raw.split([',', '\n', ';'])
1136 .map(str::trim)
1137 .filter(|part| !part.is_empty())
1138 .map(EgressRule::parse)
1139 .collect()
1140}
1141
1142fn parse_default_action(raw: &str) -> Result<DefaultAction, VmError> {
1143 match raw.trim().to_ascii_lowercase().as_str() {
1144 "" | "allow" => Ok(DefaultAction::Allow),
1145 "deny" => Ok(DefaultAction::Deny),
1146 other => Err(vm_error(format!(
1147 "egress_policy: default must be `allow` or `deny`, got `{other}`"
1148 ))),
1149 }
1150}
1151
1152fn policy_summary() -> VmValue {
1153 let configured = {
1154 let guard = state().read().expect("egress policy state poisoned");
1155 #[cfg(test)]
1156 {
1157 guard
1158 .test_policies
1159 .get(&std::thread::current().id())
1160 .cloned()
1161 }
1162 #[cfg(not(test))]
1163 {
1164 guard.policy.clone()
1165 }
1166 };
1167 let mut dict = BTreeMap::new();
1168 if let Some(configured) = configured {
1169 dict.insert("configured".to_string(), VmValue::Bool(true));
1170 dict.put_str("source", configured.source);
1171 dict.put_str(
1172 "default",
1173 match configured.policy.default {
1174 DefaultAction::Allow => "allow",
1175 DefaultAction::Deny => "deny",
1176 },
1177 );
1178 dict.insert(
1179 "allow".to_string(),
1180 VmValue::List(std::sync::Arc::new(
1181 configured
1182 .policy
1183 .allow
1184 .iter()
1185 .map(|rule| VmValue::String(arcstr::ArcStr::from(rule.raw.as_str())))
1186 .collect(),
1187 )),
1188 );
1189 dict.insert(
1190 "deny".to_string(),
1191 VmValue::List(std::sync::Arc::new(
1192 configured
1193 .policy
1194 .deny
1195 .iter()
1196 .map(|rule| VmValue::String(arcstr::ArcStr::from(rule.raw.as_str())))
1197 .collect(),
1198 )),
1199 );
1200 let (mode, allow_loopback) = effective_ssrf_settings(Some(&configured.policy));
1201 dict.put_str(
1202 "block_private",
1203 match mode {
1204 SsrfMode::BlockPrivate => "private",
1205 SsrfMode::Off => "off",
1206 },
1207 );
1208 dict.insert("allow_loopback".to_string(), VmValue::Bool(allow_loopback));
1209 } else {
1210 dict.insert("configured".to_string(), VmValue::Bool(false));
1211 }
1212 VmValue::dict(dict)
1213}
1214
1215impl EgressRule {
1216 fn parse(raw: &str) -> Result<Self, VmError> {
1217 let raw = raw.trim();
1218 if raw.is_empty() {
1219 return Err(vm_error("egress_policy: empty egress rule"));
1220 }
1221 let (host, port) = parse_rule_host_port(raw)?;
1222 let host = normalize_host(&host);
1223 let matcher = if let Some(suffix) = host.strip_prefix("*.") {
1224 if suffix.is_empty() {
1225 return Err(vm_error(format!(
1226 "egress_policy: invalid wildcard rule `{raw}`"
1227 )));
1228 }
1229 EgressMatcher::Suffix(suffix.to_string())
1230 } else if host.contains('/') {
1231 EgressMatcher::Cidr(IpNet::from_str(&host).map_err(|error| {
1232 vm_error(format!("egress_policy: invalid CIDR rule `{raw}`: {error}"))
1233 })?)
1234 } else if let Ok(ip) = IpAddr::from_str(&host) {
1235 EgressMatcher::Ip(ip)
1236 } else {
1237 EgressMatcher::Host(host)
1238 };
1239 Ok(Self {
1240 raw: raw.to_string(),
1241 matcher,
1242 port,
1243 })
1244 }
1245
1246 fn matches(&self, target: &EgressTarget) -> bool {
1247 if let Some(port) = self.port {
1248 if target.port != Some(port) {
1249 return false;
1250 }
1251 }
1252 match &self.matcher {
1253 EgressMatcher::Host(host) => target.host == *host,
1254 EgressMatcher::Suffix(suffix) => {
1255 crate::harness_net::host_has_dns_suffix(&target.host, suffix)
1256 }
1257 EgressMatcher::Ip(ip) => target.ip == Some(*ip),
1258 EgressMatcher::Cidr(net) => target.ip.is_some_and(|ip| net.contains(&ip)),
1259 }
1260 }
1261
1262 fn is_ip_matcher(&self) -> bool {
1267 matches!(self.matcher, EgressMatcher::Ip(_) | EgressMatcher::Cidr(_))
1268 }
1269
1270 fn matches_resolved_ip(&self, ip: IpAddr, request_port: Option<u16>) -> bool {
1275 if let Some(port) = self.port {
1276 if request_port != Some(port) {
1277 return false;
1278 }
1279 }
1280 match &self.matcher {
1281 EgressMatcher::Ip(rule_ip) => *rule_ip == ip,
1282 EgressMatcher::Cidr(net) => net.contains(&ip),
1283 EgressMatcher::Host(_) | EgressMatcher::Suffix(_) => false,
1284 }
1285 }
1286}
1287
1288#[derive(Clone, Debug)]
1289struct EgressTarget {
1290 host: String,
1291 ip: Option<IpAddr>,
1292 port: Option<u16>,
1293}
1294
1295impl EgressTarget {
1296 fn parse(raw_url: &str) -> Result<Self, VmError> {
1297 let parsed = Url::parse(raw_url)
1298 .map_err(|error| vm_error(format!("egress: invalid URL `{raw_url}`: {error}")))?;
1299 let host = parsed
1300 .host_str()
1301 .ok_or_else(|| vm_error(format!("egress: URL `{raw_url}` does not include a host")))?;
1302 let host = normalize_host(host);
1303 let ip = IpAddr::from_str(&host).ok();
1304 Ok(Self {
1305 host,
1306 ip,
1307 port: parsed.port_or_known_default(),
1308 })
1309 }
1310
1311 fn is_loopback_host(&self) -> bool {
1312 self.host == "localhost"
1313 || self.ip.is_some_and(|ip| match ip {
1314 IpAddr::V4(v4) => v4.is_loopback(),
1315 IpAddr::V6(v6) => {
1316 v6.is_loopback()
1317 || v6
1318 .to_ipv4_mapped()
1319 .is_some_and(|mapped| mapped.is_loopback())
1320 }
1321 })
1322 }
1323}
1324
1325fn parse_rule_host_port(raw: &str) -> Result<(String, Option<u16>), VmError> {
1326 if let Ok(url) = Url::parse(raw) {
1327 if let Some(host) = url.host_str() {
1328 return Ok((host.to_string(), url.port_or_known_default()));
1329 }
1330 }
1331 let raw = raw.trim();
1332 if let Some(rest) = raw.strip_prefix('[') {
1333 let Some((host, suffix)) = rest.split_once(']') else {
1334 return Err(vm_error(format!(
1335 "egress_policy: invalid bracketed host rule `{raw}`"
1336 )));
1337 };
1338 let port = if let Some(port) = suffix.strip_prefix(':') {
1339 Some(parse_port(raw, port)?)
1340 } else if suffix.is_empty() {
1341 None
1342 } else {
1343 return Err(vm_error(format!(
1344 "egress_policy: invalid bracketed host rule `{raw}`"
1345 )));
1346 };
1347 return Ok((host.to_string(), port));
1348 }
1349 if let Some((host, port)) = split_host_port(raw) {
1350 return Ok((host.to_string(), Some(parse_port(raw, port)?)));
1351 }
1352 Ok((raw.to_string(), None))
1353}
1354
1355fn split_host_port(raw: &str) -> Option<(&str, &str)> {
1356 let (host, port) = raw.rsplit_once(':')?;
1357 if host.contains(':') || port.is_empty() || !port.chars().all(|ch| ch.is_ascii_digit()) {
1358 return None;
1359 }
1360 Some((host, port))
1361}
1362
1363fn parse_port(rule: &str, raw: &str) -> Result<u16, VmError> {
1364 raw.parse::<u16>()
1365 .map_err(|error| vm_error(format!("egress_policy: invalid port in `{rule}`: {error}")))
1366}
1367
1368fn normalize_host(host: &str) -> String {
1369 host.trim()
1370 .trim_end_matches('.')
1371 .trim_matches('[')
1372 .trim_matches(']')
1373 .to_ascii_lowercase()
1374}
1375
1376fn redact_sensitive_url(url: &str) -> String {
1377 crate::redact::current_policy().redact_url(url)
1378}
1379
1380fn vm_error(message: impl Into<String>) -> VmError {
1381 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(message.into())))
1382}
1383
1384impl EgressBlocked {
1385 pub(crate) fn to_vm_error(&self) -> VmError {
1386 let mut dict = BTreeMap::new();
1387 dict.put_str("type", "EgressBlocked");
1388 dict.put_str("category", "egress_blocked");
1389 dict.put_str("message", self.to_string());
1390 dict.put_str("surface", self.surface.as_str());
1391 dict.put_str("url", self.url.as_str());
1392 dict.put_str("host", self.host.as_str());
1393 dict.insert(
1394 "port".to_string(),
1395 self.port
1396 .map(|port| VmValue::Int(port as i64))
1397 .unwrap_or(VmValue::Nil),
1398 );
1399 dict.put_str("reason", self.reason.as_str());
1400 VmError::Thrown(VmValue::dict(dict))
1401 }
1402}
1403
1404impl std::fmt::Display for EgressBlocked {
1405 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1406 match self.port {
1407 Some(port) => write!(
1408 f,
1409 "EgressBlocked: {} blocked {}:{} for {} ({})",
1410 self.surface, self.host, port, self.url, self.reason
1411 ),
1412 None => write!(
1413 f,
1414 "EgressBlocked: {} blocked {} for {} ({})",
1415 self.surface, self.host, self.url, self.reason
1416 ),
1417 }
1418 }
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423 use super::*;
1424 use rcgen::generate_simple_self_signed;
1425 use rustls::pki_types::PrivatePkcs8KeyDer;
1426 use rustls::{ServerConfig, ServerConnection, StreamOwned};
1427 use std::io::{Read, Write};
1428 use std::net::TcpListener;
1429 use std::sync::{Arc, Once};
1430
1431 fn install(config: &[(&str, VmValue)]) -> std::sync::MutexGuard<'static, ()> {
1432 let guard = test_env_lock();
1433 reset_egress_policy_for_tests();
1434 let map = config
1435 .iter()
1436 .cloned()
1437 .map(|(key, value)| (key.to_string(), value))
1438 .collect();
1439 let policy = policy_from_config(&map).expect("policy parses");
1440 install_policy(policy, "test").expect("policy installs");
1441 guard
1442 }
1443
1444 fn strings(values: &[&str]) -> VmValue {
1445 VmValue::List(std::sync::Arc::new(
1446 values
1447 .iter()
1448 .map(|value| VmValue::String(arcstr::ArcStr::from(*value)))
1449 .collect(),
1450 ))
1451 }
1452
1453 #[test]
1454 fn exact_host_and_port_restriction() {
1455 let _guard = install(&[
1456 ("allow", strings(&["api.example.com:443"])),
1457 ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
1458 ]);
1459 assert!(check_url("http_get", "https://api.example.com/users")
1460 .unwrap()
1461 .is_none());
1462 let blocked = check_url("http_get", "http://api.example.com/users")
1463 .unwrap()
1464 .expect("port mismatch blocks");
1465 assert_eq!(blocked.host, "api.example.com");
1466 assert_eq!(blocked.port, Some(80));
1467 }
1468
1469 #[test]
1470 fn suffix_wildcard_matches_subdomains_only() {
1471 let _guard = install(&[
1472 ("allow", strings(&["*.example.com"])),
1473 ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
1474 ]);
1475 assert!(check_url("http_get", "https://api.example.com")
1476 .unwrap()
1477 .is_none());
1478 assert!(check_url("http_get", "https://example.com")
1479 .unwrap()
1480 .is_some());
1481 }
1482
1483 #[test]
1484 fn cidr_matches_ip_literals() {
1485 let _guard = install(&[
1486 ("allow", strings(&["127.0.0.0/8"])),
1487 ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
1488 ]);
1489 assert!(check_url("http_get", "http://127.10.20.30:8080")
1490 .unwrap()
1491 .is_none());
1492 assert!(check_url("http_get", "http://192.168.1.1")
1493 .unwrap()
1494 .is_some());
1495 }
1496
1497 #[test]
1498 fn deny_overrides_allow() {
1499 let _guard = install(&[
1500 ("allow", strings(&["*.example.com"])),
1501 ("deny", strings(&["blocked.example.com"])),
1502 ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
1503 ]);
1504 let blocked = check_url("http_get", "https://blocked.example.com")
1505 .unwrap()
1506 .expect("deny wins");
1507 assert!(blocked.reason.contains("deny rule"));
1508 }
1509
1510 #[test]
1511 fn blocked_urls_redact_sensitive_query_values() {
1512 let _guard = install(&[("default", VmValue::String(arcstr::ArcStr::from("deny")))]);
1513 let blocked = check_url(
1514 "http_get",
1515 "https://api.example.com/resource?access_token=secret-token&ok=1",
1516 )
1517 .unwrap()
1518 .expect("default deny blocks");
1519
1520 assert_eq!(
1521 blocked.url,
1522 "https://api.example.com/resource?access_token=%5Bredacted%5D&ok=1"
1523 );
1524 assert!(!blocked.to_string().contains("secret-token"));
1525 }
1526
1527 #[test]
1528 fn require_explicit_policy_blocks_unconfigured_egress() {
1529 let _guard = test_env_lock();
1530 reset_egress_policy_for_tests();
1531 {
1532 let _scope = require_explicit_egress_policy_for_host();
1533 let blocked = check_url("http_get", "https://api.example.com/status")
1534 .unwrap()
1535 .expect("missing policy blocks");
1536 assert_eq!(blocked.reason, "no egress policy configured");
1537 assert_eq!(blocked.host, "api.example.com");
1538 }
1539 assert!(check_url("http_get", "https://api.example.com/status")
1540 .unwrap()
1541 .is_none());
1542 reset_egress_policy_for_tests();
1543 }
1544
1545 #[test]
1546 fn reset_thread_local_state_clears_required_policy_scope() {
1547 let _guard = test_env_lock();
1548 reset_egress_policy_for_tests();
1549 let _scope = require_explicit_egress_policy_for_host();
1550
1551 crate::reset_thread_local_state();
1552
1553 assert!(check_url("http_get", "https://api.example.com/status")
1554 .unwrap()
1555 .is_none());
1556 reset_egress_policy_for_tests();
1557 }
1558
1559 #[test]
1560 fn explicit_environment_policy_satisfies_required_policy_scope() {
1561 let _guard = test_env_lock();
1562 reset_egress_policy_for_tests();
1563 std::env::set_var(HARN_EGRESS_ALLOW_ENV, "api.example.com");
1564 std::env::set_var(HARN_EGRESS_DEFAULT_ENV, "deny");
1565 let _scope = require_explicit_egress_policy_for_host();
1566
1567 assert!(check_url("http_get", "https://api.example.com/status")
1568 .unwrap()
1569 .is_none());
1570 assert!(check_url("http_get", "https://other.example.com/status")
1571 .unwrap()
1572 .is_some());
1573
1574 std::env::remove_var(HARN_EGRESS_ALLOW_ENV);
1575 std::env::remove_var(HARN_EGRESS_DEFAULT_ENV);
1576 reset_egress_policy_for_tests();
1577 }
1578
1579 #[test]
1580 fn env_seeding_is_honored() {
1581 let _guard = test_env_lock();
1582 reset_egress_policy_for_tests();
1583 std::env::set_var(HARN_EGRESS_ALLOW_ENV, "");
1584 std::env::set_var(HARN_EGRESS_DENY_ENV, "blocked-env.example.com");
1585 std::env::set_var(HARN_EGRESS_DEFAULT_ENV, "allow");
1586 assert!(check_url("http_get", "https://env.example.com")
1587 .unwrap()
1588 .is_none());
1589 assert!(check_url("http_get", "https://blocked-env.example.com")
1590 .unwrap()
1591 .is_some());
1592 std::env::remove_var(HARN_EGRESS_ALLOW_ENV);
1593 std::env::remove_var(HARN_EGRESS_DENY_ENV);
1594 std::env::remove_var(HARN_EGRESS_DEFAULT_ENV);
1595 reset_egress_policy_for_tests();
1596 }
1597
1598 #[tokio::test]
1599 async fn reqwest_redirect_policy_blocks_https_to_http_downgrade() {
1600 install_rustls_provider();
1601
1602 let cert =
1603 generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()])
1604 .expect("generate cert");
1605 let listener = TcpListener::bind("127.0.0.1:0").expect("bind tls listener");
1606 let port = listener.local_addr().expect("tls addr").port();
1607 let server_config = Arc::new(
1608 ServerConfig::builder()
1609 .with_no_client_auth()
1610 .with_single_cert(
1611 vec![cert.cert.der().clone()],
1612 PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()).into(),
1613 )
1614 .expect("build tls config"),
1615 );
1616 let thread = std::thread::spawn(move || {
1617 let (tcp, _) = listener.accept().expect("accept tls client");
1618 let conn = ServerConnection::new(server_config).expect("server connection");
1619 let mut stream = StreamOwned::new(conn, tcp);
1620 let request = read_http_request(&mut stream);
1621 assert!(request.starts_with("GET /start HTTP/1.1\r\n"));
1622 write_http_redirect_response(
1623 &mut stream,
1624 "http://public.example.com/next?access_token=target-secret",
1625 );
1626 });
1627
1628 let client = reqwest::Client::builder()
1629 .danger_accept_invalid_certs(true)
1630 .timeout(std::time::Duration::from_secs(2))
1631 .redirect(redirect_policy("test_redirect", 10))
1632 .build()
1633 .expect("client builds");
1634 let error = client
1635 .get(format!("https://localhost:{port}/start"))
1636 .send()
1637 .await
1638 .expect_err("downgrade redirect should be blocked by policy");
1639 assert!(error.is_redirect(), "{error}");
1640 let redacted = redact_reqwest_error(&error);
1641 assert!(!redacted.contains("target-secret"), "{redacted}");
1642
1643 thread.join().expect("tls thread");
1644 }
1645
1646 #[test]
1647 fn redirect_blocks_public_https_to_http_downgrade() {
1648 let _guard = test_env_lock();
1649 reset_egress_policy_for_tests();
1650 let blocked = check_redirect_url(
1651 "http_redirect",
1652 Some("https://api.example.com/start?access_token=source-secret"),
1653 "http://public.example.com/next?access_token=target-secret",
1654 )
1655 .unwrap()
1656 .expect("public https-to-http redirect is blocked");
1657
1658 assert_eq!(blocked.host, "public.example.com");
1659 assert_eq!(blocked.port, Some(80));
1660 assert_eq!(
1661 blocked.reason,
1662 "https redirect target downgrades to insecure http"
1663 );
1664 assert!(!blocked.url.contains("target-secret"), "{}", blocked.url);
1665 reset_egress_policy_for_tests();
1666 }
1667
1668 #[test]
1669 fn redirect_allows_same_scheme_and_secure_upgrade() {
1670 let _guard = test_env_lock();
1671 reset_egress_policy_for_tests();
1672
1673 for (previous, target) in [
1674 (
1675 "https://api.example.com/start",
1676 "https://cdn.example.com/next",
1677 ),
1678 (
1679 "http://api.example.com/start",
1680 "http://cdn.example.com/next",
1681 ),
1682 (
1683 "http://api.example.com/start",
1684 "https://cdn.example.com/next",
1685 ),
1686 ] {
1687 assert!(
1688 check_redirect_url("http_redirect", Some(previous), target)
1689 .unwrap()
1690 .is_none(),
1691 "{previous} -> {target} should be allowed"
1692 );
1693 }
1694
1695 reset_egress_policy_for_tests();
1696 }
1697
1698 #[test]
1699 fn loopback_https_to_http_redirect_requires_loopback_hatch() {
1700 let _guard = test_env_lock();
1701 reset_egress_policy_for_tests();
1702 assert!(
1703 check_redirect_url(
1704 "http_redirect",
1705 Some("https://api.example.com/start"),
1706 "http://127.0.0.1:7777/callback",
1707 )
1708 .unwrap()
1709 .is_some(),
1710 "loopback downgrade is blocked without the explicit loopback hatch"
1711 );
1712
1713 install_test_policy(&[
1714 (
1715 "block_private",
1716 VmValue::String(arcstr::ArcStr::from("private")),
1717 ),
1718 ("allow_loopback", VmValue::Bool(true)),
1719 ]);
1720 assert!(
1721 check_redirect_url(
1722 "http_redirect",
1723 Some("https://api.example.com/start"),
1724 "http://127.0.0.1:7777/callback",
1725 )
1726 .unwrap()
1727 .is_none(),
1728 "explicit allow_loopback keeps loopback redirect workflows available"
1729 );
1730 assert!(
1731 check_redirect_url(
1732 "http_redirect",
1733 Some("https://api.example.com/start"),
1734 "http://LOCALHOST.:7777/callback",
1735 )
1736 .unwrap()
1737 .is_none(),
1738 "localhost follows the same explicit loopback hatch after normalization"
1739 );
1740 assert!(
1741 check_redirect_url(
1742 "http_redirect",
1743 Some("https://api.example.com/start"),
1744 "http://[::1]:7777/callback",
1745 )
1746 .unwrap()
1747 .is_none(),
1748 "IPv6 loopback follows the same explicit loopback hatch"
1749 );
1750 assert!(
1751 check_redirect_url(
1752 "http_redirect",
1753 Some("https://api.example.com/start"),
1754 "http://[::ffff:127.0.0.1]:7777/callback",
1755 )
1756 .unwrap()
1757 .is_none(),
1758 "IPv4-mapped IPv6 loopback follows the same explicit loopback hatch"
1759 );
1760 assert!(
1761 check_redirect_url(
1762 "http_redirect",
1763 Some("https://api.example.com/start"),
1764 "http://public.example.com/callback",
1765 )
1766 .unwrap()
1767 .is_some(),
1768 "allow_loopback does not exempt public insecure redirects"
1769 );
1770
1771 reset_egress_policy_for_tests();
1772 }
1773
1774 fn install_rustls_provider() {
1775 static INSTALL: Once = Once::new();
1776 INSTALL.call_once(|| {
1777 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
1778 });
1779 }
1780
1781 fn read_http_request<T: Read>(stream: &mut T) -> String {
1782 let mut buffer = Vec::new();
1783 let mut chunk = [0u8; 4096];
1784 loop {
1785 let read = stream.read(&mut chunk).expect("read request");
1786 assert!(read > 0, "request closed before headers");
1787 buffer.extend_from_slice(&chunk[..read]);
1788 if buffer.windows(4).any(|window| window == b"\r\n\r\n") {
1789 return String::from_utf8_lossy(&buffer).into_owned();
1790 }
1791 }
1792 }
1793
1794 fn write_http_redirect_response<T: Write>(stream: &mut T, location: &str) {
1795 let response = format!(
1796 "HTTP/1.1 302 Found\r\ncontent-length: 0\r\nconnection: close\r\nlocation: {location}\r\n\r\n"
1797 );
1798 stream
1799 .write_all(response.as_bytes())
1800 .expect("write redirect response");
1801 stream.flush().expect("flush redirect response");
1802 }
1803
1804 fn install_block_private() -> std::sync::MutexGuard<'static, ()> {
1810 install(&[(
1811 "block_private",
1812 VmValue::String(arcstr::ArcStr::from("private")),
1813 )])
1814 }
1815
1816 #[test]
1817 fn block_private_rejects_loopback_and_metadata_literals() {
1818 let _guard = install_block_private();
1819 for url in [
1820 "http://127.0.0.1",
1821 "https://169.254.169.254",
1822 "http://10.0.0.1",
1823 "https://192.168.1.1",
1824 "https://[::1]",
1825 "https://[fe80::1]",
1826 "https://[::ffff:127.0.0.1]",
1827 "https://100.64.0.1",
1828 ] {
1829 let blocked = check_url("http_get", url)
1830 .unwrap()
1831 .unwrap_or_else(|| panic!("expected block for {url}"));
1832 assert!(
1833 blocked.reason.contains("disallowed address"),
1834 "{url}: {}",
1835 blocked.reason
1836 );
1837 }
1838 }
1839
1840 #[test]
1841 fn block_private_allows_public_literal() {
1842 let _guard = install_block_private();
1843 assert!(check_url("http_get", "https://93.184.216.34")
1844 .unwrap()
1845 .is_none());
1846 assert!(check_url("http_get", "http://8.8.8.8").unwrap().is_none());
1847 }
1848
1849 #[test]
1850 fn block_private_wins_over_allow_rule() {
1851 let _guard = install(&[
1853 ("allow", strings(&["127.0.0.1"])),
1854 (
1855 "block_private",
1856 VmValue::String(arcstr::ArcStr::from("private")),
1857 ),
1858 ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
1859 ]);
1860 assert!(check_url("http_get", "http://127.0.0.1").unwrap().is_some());
1861 }
1862
1863 #[test]
1864 fn block_private_redacts_secret_in_url() {
1865 let _guard = install_block_private();
1866 let blocked = check_url(
1867 "http_get",
1868 "http://127.0.0.1/resource?token=SECRET&access_token=zzz&ok=1",
1869 )
1870 .unwrap()
1871 .expect("loopback blocked");
1872 assert!(!blocked.url.contains("SECRET"), "{}", blocked.url);
1873 assert!(!blocked.url.contains("zzz"), "{}", blocked.url);
1874 assert!(!blocked.to_string().contains("SECRET"));
1875 assert!(!blocked.reason.contains("SECRET"));
1876 assert!(blocked.reason.contains("127.0.0.1"));
1878 }
1879
1880 #[test]
1881 fn allow_loopback_hatch_permits_loopback_literal() {
1882 let _guard = install(&[
1883 (
1884 "block_private",
1885 VmValue::String(arcstr::ArcStr::from("private")),
1886 ),
1887 ("allow_loopback", VmValue::Bool(true)),
1888 ]);
1889 assert!(check_url("http_get", "http://127.0.0.1").unwrap().is_none());
1890 assert!(check_url("http_get", "https://[::1]").unwrap().is_none());
1891 assert!(check_url("http_get", "https://169.254.169.254")
1893 .unwrap()
1894 .is_some());
1895 }
1896
1897 #[test]
1898 fn default_on_blocks_loopback_under_run_scope() {
1899 let _guard = test_env_lock();
1900 reset_egress_policy_for_tests();
1901 {
1902 let _scope = require_ssrf_guard_for_host();
1903 assert!(check_url("http_get", "http://127.0.0.1").unwrap().is_some());
1906 assert!(check_url("http_get", "https://169.254.169.254")
1907 .unwrap()
1908 .is_some());
1909 assert!(check_url("http_get", "https://8.8.8.8").unwrap().is_none());
1911 }
1912 assert!(check_url("http_get", "http://127.0.0.1").unwrap().is_none());
1914 reset_egress_policy_for_tests();
1915 }
1916
1917 #[test]
1918 fn block_private_off_opts_out_under_run_scope() {
1919 let _guard = test_env_lock();
1920 reset_egress_policy_for_tests();
1921 install_test_policy(&[(
1922 "block_private",
1923 VmValue::String(arcstr::ArcStr::from("off")),
1924 )]);
1925 let _scope = require_ssrf_guard_for_host();
1926 assert!(check_url("http_get", "http://127.0.0.1").unwrap().is_none());
1928 reset_egress_policy_for_tests();
1929 }
1930
1931 #[test]
1932 fn env_allow_loopback_opts_out_under_run_scope() {
1933 let _guard = test_env_lock();
1934 reset_egress_policy_for_tests();
1935 std::env::set_var(HARN_EGRESS_ALLOW_LOOPBACK_ENV, "1");
1936 let _scope = require_ssrf_guard_for_host();
1937 assert!(check_url("http_get", "http://127.0.0.1").unwrap().is_none());
1939 assert!(check_url("http_get", "https://169.254.169.254")
1941 .unwrap()
1942 .is_some());
1943 std::env::remove_var(HARN_EGRESS_ALLOW_LOOPBACK_ENV);
1944 reset_egress_policy_for_tests();
1945 }
1946
1947 #[test]
1948 fn current_ssrf_client_settings_reflects_guard_scope() {
1949 let _guard = test_env_lock();
1950 reset_egress_policy_for_tests();
1951 assert_eq!(current_ssrf_client_settings(), (false, false));
1952 let _scope = require_ssrf_guard_for_host();
1953 assert_eq!(current_ssrf_client_settings(), (true, false));
1954 drop(_scope);
1955 reset_egress_policy_for_tests();
1956 assert_eq!(current_ssrf_client_settings(), (false, false));
1957 }
1958
1959 fn spawn_one_shot_ok_server() -> (u16, std::thread::JoinHandle<()>) {
1962 use std::io::{Read, Write};
1963 let listener =
1964 std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback probe server");
1965 let port = listener.local_addr().expect("probe server addr").port();
1966 let handle = std::thread::spawn(move || {
1967 if let Ok((mut stream, _)) = listener.accept() {
1968 let mut buf = [0u8; 1024];
1969 let _ = stream.read(&mut buf);
1970 let _ = stream.write_all(
1971 b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
1972 );
1973 let _ = stream.flush();
1974 }
1975 });
1976 (port, handle)
1977 }
1978
1979 #[tokio::test]
1985 async fn install_ssrf_guard_blocks_loopback_hostname_and_passes_when_off() {
1986 let (port, server) = spawn_one_shot_ok_server();
1993 reset_egress_policy_for_tests();
1994 {
1995 let _scope = require_ssrf_guard_for_host();
1996 let client = install_ssrf_guard(reqwest::Client::builder())
1997 .build()
1998 .expect("guarded client builds");
1999 let result = client
2000 .get(format!("http://localhost:{port}/probe"))
2001 .send()
2002 .await;
2003 assert!(
2004 result.is_err(),
2005 "guarded client must not reach a loopback hostname, got {result:?}"
2006 );
2007 }
2008 drop(server);
2010
2011 let (port, server) = spawn_one_shot_ok_server();
2014 reset_egress_policy_for_tests();
2015 install_test_policy(&[(
2016 "block_private",
2017 VmValue::String(arcstr::ArcStr::from("off")),
2018 )]);
2019 {
2020 let _scope = require_ssrf_guard_for_host();
2021 let client = install_ssrf_guard(reqwest::Client::builder())
2022 .build()
2023 .expect("unguarded client builds");
2024 let response = client
2025 .get(format!("http://localhost:{port}/probe"))
2026 .send()
2027 .await
2028 .expect("block_private:off permits loopback hostname");
2029 assert_eq!(response.status().as_u16(), 200);
2030 }
2031 server.join().expect("probe server thread");
2032 reset_egress_policy_for_tests();
2033 }
2034
2035 fn policy(config: &[(&str, VmValue)]) -> ConfiguredPolicy {
2044 let map = config
2045 .iter()
2046 .cloned()
2047 .map(|(key, value)| (key.to_string(), value))
2048 .collect();
2049 ConfiguredPolicy {
2050 source: "test",
2051 policy: policy_from_config(&map).expect("policy parses"),
2052 }
2053 }
2054
2055 fn ips(values: &[&str]) -> Vec<IpAddr> {
2056 values.iter().map(|v| v.parse().unwrap()).collect()
2057 }
2058
2059 fn target(url: &str) -> EgressTarget {
2060 EgressTarget::parse(url).expect("target parses")
2061 }
2062
2063 #[test]
2064 fn resolved_ip_in_deny_cidr_blocks_hostname() {
2065 let pol = policy(&[("deny", strings(&["203.0.113.0/24"]))]);
2068 let blocked = evaluate_resolved_addrs(
2069 Some(&pol),
2070 "http_get",
2071 "https://evil.example.com",
2072 &target("https://evil.example.com"),
2073 &ips(&["203.0.113.7"]),
2074 false,
2075 )
2076 .expect("resolved address in deny CIDR is blocked");
2077 assert!(
2078 blocked.reason.contains("deny rule `203.0.113.0/24`"),
2079 "{}",
2080 blocked.reason
2081 );
2082 assert_eq!(blocked.host, "evil.example.com");
2083 }
2084
2085 #[test]
2086 fn resolved_ip_in_deny_ip_literal_blocks_hostname() {
2087 let pol = policy(&[("deny", strings(&["198.51.100.9"]))]);
2088 let blocked = evaluate_resolved_addrs(
2089 Some(&pol),
2090 "http_get",
2091 "https://name.example.com",
2092 &target("https://name.example.com"),
2093 &ips(&["198.51.100.9"]),
2094 false,
2095 )
2096 .expect("resolved address matching deny IP is blocked");
2097 assert!(
2098 blocked.reason.contains("deny rule `198.51.100.9`"),
2099 "{}",
2100 blocked.reason
2101 );
2102 }
2103
2104 #[test]
2105 fn resolved_ip_outside_deny_cidr_is_allowed() {
2106 let pol = policy(&[("deny", strings(&["203.0.113.0/24"]))]);
2107 assert!(evaluate_resolved_addrs(
2108 Some(&pol),
2109 "http_get",
2110 "https://ok.example.com",
2111 &target("https://ok.example.com"),
2112 &ips(&["198.51.100.7"]),
2113 false,
2114 )
2115 .is_none());
2116 }
2117
2118 #[test]
2119 fn resolved_ip_in_allow_cidr_permits_hostname_under_default_deny() {
2120 let pol = policy(&[
2124 ("allow", strings(&["10.0.0.0/8"])),
2125 ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
2126 ]);
2127 assert!(evaluate_resolved_addrs(
2128 Some(&pol),
2129 "http_get",
2130 "https://internal.example.com",
2131 &target("https://internal.example.com"),
2132 &ips(&["10.1.2.3"]),
2133 true,
2134 )
2135 .is_none());
2136 }
2137
2138 #[test]
2139 fn resolved_ip_outside_allow_cidr_blocks_hostname_under_default_deny() {
2140 let pol = policy(&[
2141 ("allow", strings(&["10.0.0.0/8"])),
2142 ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
2143 ]);
2144 let blocked = evaluate_resolved_addrs(
2145 Some(&pol),
2146 "http_get",
2147 "https://outside.example.com",
2148 &target("https://outside.example.com"),
2149 &ips(&["8.8.8.8"]),
2150 true,
2151 )
2152 .expect("resolved address outside allow CIDR is blocked under default deny");
2153 assert_eq!(blocked.reason, "no allow rule matched");
2154 }
2155
2156 #[test]
2157 fn deny_cidr_wins_over_allow_cidr_on_resolved_ip() {
2158 let pol = policy(&[
2161 ("allow", strings(&["10.0.0.0/8"])),
2162 ("deny", strings(&["10.6.0.0/16"])),
2163 ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
2164 ]);
2165 let blocked = evaluate_resolved_addrs(
2166 Some(&pol),
2167 "http_get",
2168 "https://internal.example.com",
2169 &target("https://internal.example.com"),
2170 &ips(&["10.6.1.2"]),
2171 true,
2172 )
2173 .expect("deny CIDR wins over allow CIDR");
2174 assert!(
2175 blocked.reason.contains("deny rule `10.6.0.0/16`"),
2176 "{}",
2177 blocked.reason
2178 );
2179 }
2180
2181 #[test]
2182 fn mixed_resolution_blocks_if_any_address_is_denied() {
2183 let pol = policy(&[("deny", strings(&["203.0.113.0/24"]))]);
2186 assert!(evaluate_resolved_addrs(
2187 Some(&pol),
2188 "http_get",
2189 "https://mixed.example.com",
2190 &target("https://mixed.example.com"),
2191 &ips(&["8.8.8.8", "203.0.113.9"]),
2192 false,
2193 )
2194 .is_some());
2195 }
2196
2197 #[test]
2198 fn port_scoped_deny_cidr_only_matches_that_port_on_resolved_ip() {
2199 let pol = policy(&[("deny", strings(&["203.0.113.0/24:443"]))]);
2202 assert!(evaluate_resolved_addrs(
2204 Some(&pol),
2205 "http_get",
2206 "https://p.example.com",
2207 &target("https://p.example.com"),
2208 &ips(&["203.0.113.7"]),
2209 false,
2210 )
2211 .is_some());
2212 assert!(evaluate_resolved_addrs(
2214 Some(&pol),
2215 "http_get",
2216 "http://p.example.com",
2217 &target("http://p.example.com"),
2218 &ips(&["203.0.113.7"]),
2219 false,
2220 )
2221 .is_none());
2222 }
2223
2224 #[test]
2225 fn ssrf_block_still_applies_to_resolved_ip() {
2226 let pol = policy(&[(
2229 "block_private",
2230 VmValue::String(arcstr::ArcStr::from("private")),
2231 )]);
2232 let blocked = evaluate_resolved_addrs(
2233 Some(&pol),
2234 "http_get",
2235 "https://rebind.example.com",
2236 &target("https://rebind.example.com"),
2237 &ips(&["10.0.0.1"]),
2238 false,
2239 )
2240 .expect("private resolved address blocked");
2241 assert!(
2242 blocked.reason.contains("disallowed address"),
2243 "{}",
2244 blocked.reason
2245 );
2246 }
2247
2248 #[test]
2249 fn check_url_defers_hostname_with_cidr_allowlist_under_default_deny() {
2250 let _guard = install(&[
2253 ("allow", strings(&["10.0.0.0/8"])),
2254 ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
2255 ]);
2256 match check_url_decision("http_get", "https://internal.example.com").unwrap() {
2257 SyncCheck::DeferToResolution(_) => {}
2258 other => panic!(
2259 "expected DeferToResolution, got {}",
2260 match other {
2261 SyncCheck::Allowed => "Allowed",
2262 SyncCheck::Blocked(_) => "Blocked",
2263 SyncCheck::DeferToResolution(_) => unreachable!(),
2264 }
2265 ),
2266 }
2267 assert!(matches!(
2270 check_url_decision("http_get", "https://8.8.8.8").unwrap(),
2271 SyncCheck::Blocked(_)
2272 ));
2273 drop(_guard);
2275 let _guard = install(&[
2276 ("allow", strings(&["api.example.com"])),
2277 ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
2278 ]);
2279 assert!(matches!(
2280 check_url_decision("http_get", "https://other.example.com").unwrap(),
2281 SyncCheck::Blocked(_)
2282 ));
2283 }
2284
2285 #[test]
2286 fn resolved_ip_rules_for_drops_host_suffix_and_port_scoped_rules() {
2287 let pol = policy(&[
2291 (
2292 "deny",
2293 strings(&[
2294 "203.0.113.0/24",
2295 "198.51.100.5",
2296 "*.evil.com",
2297 "10.0.0.0/8:443",
2298 ]),
2299 ),
2300 ("default", VmValue::String(arcstr::ArcStr::from("deny"))),
2301 ]);
2302 let rules = resolved_ip_rules_for(&pol.policy);
2303 assert_eq!(
2304 rules.deny.len(),
2305 2,
2306 "host/suffix + port-scoped rules dropped"
2307 );
2308 assert!(rules.deny.contains(&"203.0.113.0/24".parse().unwrap()));
2309 assert!(rules.deny.contains(&"198.51.100.5/32".parse().unwrap()));
2310 assert!(rules.allow_active);
2311 }
2312
2313 #[test]
2314 fn current_resolved_ip_rules_reflects_installed_policy() {
2315 let _guard = install(&[("deny", strings(&["203.0.113.0/24"]))]);
2316 let rules = current_resolved_ip_rules();
2317 assert_eq!(rules.deny, vec!["203.0.113.0/24".parse().unwrap()]);
2318 }
2319}