use crate::value::VmDictExt;
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::marker::PhantomData;
use std::net::IpAddr;
use std::rc::Rc;
use std::str::FromStr;
use std::sync::{Arc, OnceLock, RwLock};
use ipnet::IpNet;
use serde_json::json;
use url::Url;
use crate::event_log::{active_event_log, EventLog, LogEvent, Topic};
use crate::value::{VmError, VmValue};
use crate::vm::Vm;
mod process_proxy;
mod provider_allow;
mod rules;
pub mod ssrf;
#[cfg(test)]
pub(crate) mod test_support;
pub(crate) use rules::{
normalize_host, parse_bool, parse_default_action, parse_rule_list, parse_rule_values,
parse_ssrf_mode, EgressTarget,
};
#[cfg(test)]
pub use test_support::reset_egress_policy_for_tests;
#[cfg(test)]
pub(crate) use test_support::{
install_test_policy, test_env_guard, EgressTestConfigGuard, EgressTestEnvGuard,
};
pub use process_proxy::{ProcessEgressAudit, ProcessEgressProxy};
pub use provider_allow::{
configured_provider_private_allow_host, install_ssrf_guard_with_private_host_allowlist,
ssrf_client_cache_key,
};
pub use ssrf::{is_disallowed_ip, GuardedResolver};
pub const HARN_EGRESS_ALLOW_ENV: &str = "HARN_EGRESS_ALLOW";
pub const HARN_EGRESS_DENY_ENV: &str = "HARN_EGRESS_DENY";
pub const HARN_EGRESS_DEFAULT_ENV: &str = "HARN_EGRESS_DEFAULT";
pub const HARN_EGRESS_BLOCK_PRIVATE_ENV: &str = "HARN_EGRESS_BLOCK_PRIVATE";
pub const HARN_EGRESS_ALLOW_LOOPBACK_ENV: &str = "HARN_EGRESS_ALLOW_LOOPBACK";
pub const EGRESS_AUDIT_TOPIC: &str = "connectors.egress.audit";
thread_local! {
static EGRESS_POLICY_CONTEXT: RefCell<Option<EgressPolicyContext>> = const { RefCell::new(None) };
#[cfg(test)]
static TEST_EGRESS_POLICY_CONTEXT: EgressPolicyContext =
EgressPolicyContext(Arc::new(RwLock::new(EgressState::default())));
static REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH: RefCell<usize> = const { RefCell::new(0) };
static REQUIRE_SSRF_GUARD_DEPTH: RefCell<usize> = const { RefCell::new(0) };
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum SsrfMode {
Off,
#[default]
BlockPrivate,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum DefaultAction {
Allow,
Deny,
}
#[derive(Clone, Debug)]
struct EgressPolicy {
allow: Vec<EgressRule>,
deny: Vec<EgressRule>,
default: DefaultAction,
block_private: Option<SsrfMode>,
allow_loopback: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct EgressRule {
raw: String,
matcher: EgressMatcher,
port: Option<u16>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum EgressMatcher {
Host(String),
Suffix(String),
Ip(IpAddr),
Cidr(IpNet),
}
impl PartialEq for EgressRule {
fn eq(&self, other: &Self) -> bool {
self.matcher == other.matcher && self.port == other.port
}
}
impl Eq for EgressRule {}
fn extend_unique<T: PartialEq>(values: &mut Vec<T>, incoming: impl IntoIterator<Item = T>) {
for value in incoming {
if !values.contains(&value) {
values.push(value);
}
}
}
fn unique<T: PartialEq>(incoming: Vec<T>) -> Vec<T> {
let mut values = Vec::with_capacity(incoming.len());
extend_unique(&mut values, incoming);
values
}
#[derive(Clone, Debug, Default)]
struct EgressState {
env_checked: bool,
policy: Option<ConfiguredPolicy>,
}
#[derive(Clone, Copy, Debug, Default)]
struct DeclaredAxes {
allow: bool,
deny: bool,
default: bool,
block_private: bool,
allow_loopback: bool,
}
#[derive(Clone, Debug, Default)]
struct AxisSources {
allow: Vec<&'static str>,
deny: Vec<&'static str>,
default: Vec<&'static str>,
block_private: Vec<&'static str>,
allow_loopback: Vec<&'static str>,
}
#[derive(Clone, Debug)]
struct ConfiguredPolicy {
sources: Vec<&'static str>,
axis_sources: AxisSources,
allow_loopback_declared: bool,
policy: EgressPolicy,
}
impl ConfiguredPolicy {
fn single(source: &'static str, policy: EgressPolicy) -> Self {
Self::first(
policy,
DeclaredAxes {
allow: true,
deny: true,
default: true,
block_private: true,
allow_loopback: true,
},
source,
)
}
fn first(mut policy: EgressPolicy, declared: DeclaredAxes, source: &'static str) -> Self {
policy.allow = unique(policy.allow);
policy.deny = unique(policy.deny);
let mut axis_sources = AxisSources::default();
if declared.allow {
axis_sources.allow.push(source);
}
if declared.deny {
axis_sources.deny.push(source);
}
if declared.default {
axis_sources.default.push(source);
}
if declared.block_private {
axis_sources.block_private.push(source);
}
if declared.allow_loopback {
axis_sources.allow_loopback.push(source);
}
Self {
sources: vec![source],
axis_sources,
allow_loopback_declared: !declared.allow_loopback || policy.allow_loopback,
policy,
}
}
fn compose(&mut self, incoming: EgressPolicy, declared: DeclaredAxes, source: &'static str) {
extend_unique(&mut self.sources, [source]);
if declared.allow {
extend_unique(&mut self.policy.allow, incoming.allow);
extend_unique(&mut self.axis_sources.allow, [source]);
}
if declared.deny {
extend_unique(&mut self.policy.deny, incoming.deny);
extend_unique(&mut self.axis_sources.deny, [source]);
}
if declared.default {
extend_unique(&mut self.axis_sources.default, [source]);
if incoming.default == DefaultAction::Deny {
self.policy.default = DefaultAction::Deny;
}
}
if declared.block_private {
extend_unique(&mut self.axis_sources.block_private, [source]);
self.policy.block_private = match (self.policy.block_private, incoming.block_private) {
(Some(SsrfMode::BlockPrivate), _) | (_, Some(SsrfMode::BlockPrivate)) => {
Some(SsrfMode::BlockPrivate)
}
(existing, None) => existing,
(_, incoming) => incoming,
};
}
if declared.allow_loopback {
extend_unique(&mut self.axis_sources.allow_loopback, [source]);
self.allow_loopback_declared &= incoming.allow_loopback;
self.policy.allow_loopback = self.allow_loopback_declared;
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EgressBlocked {
pub surface: String,
pub url: String,
pub host: String,
pub port: Option<u16>,
pub reason: String,
}
static EGRESS_STATE: OnceLock<RwLock<EgressState>> = OnceLock::new();
fn global_state() -> &'static RwLock<EgressState> {
EGRESS_STATE.get_or_init(|| RwLock::new(EgressState::default()))
}
#[derive(Clone, Debug)]
pub(crate) struct EgressPolicyContext(Arc<RwLock<EgressState>>);
fn current_policy_context() -> Option<EgressPolicyContext> {
let explicit = EGRESS_POLICY_CONTEXT.with(|context| context.borrow().clone());
if explicit.is_some() {
return explicit;
}
#[cfg(test)]
{
Some(TEST_EGRESS_POLICY_CONTEXT.with(Clone::clone))
}
#[cfg(not(test))]
{
None
}
}
fn with_state_read<T>(read: impl FnOnce(&EgressState) -> T) -> T {
if let Some(context) = current_policy_context() {
let guard = context.0.read().expect("egress policy state poisoned");
read(&guard)
} else {
let guard = global_state().read().expect("egress policy state poisoned");
read(&guard)
}
}
fn with_state_write<T>(write: impl FnOnce(&mut EgressState) -> T) -> T {
if let Some(context) = current_policy_context() {
let mut guard = context.0.write().expect("egress policy state poisoned");
write(&mut guard)
} else {
let mut guard = global_state()
.write()
.expect("egress policy state poisoned");
write(&mut guard)
}
}
pub fn register_egress_builtins(vm: &mut Vm) {
vm.register_capability_method(
harn_builtin_meta::CapabilityId::Net,
"egress_policy",
|args, _out| {
let Some(VmValue::Dict(config)) = args.first() else {
return Err(vm_error("egress_policy: requires a config dict"));
};
let (policy, declared) = policy_from_config(config)?;
install_policy(policy, declared, "stdlib")?;
Ok(policy_summary())
},
);
}
pub async fn enforce_url_allowed(surface: &str, url: &str) -> Result<(), VmError> {
let resolution_required = match check_url_decision(surface, url)? {
SyncCheck::Allowed => false,
SyncCheck::DeferToResolution(_) => true,
SyncCheck::Blocked(blocked) => {
audit_blocked(&blocked).await;
return Err(blocked.to_vm_error());
}
};
if let Some(blocked) = check_url_host_resolution(surface, url, resolution_required).await? {
audit_blocked(&blocked).await;
return Err(blocked.to_vm_error());
}
Ok(())
}
pub fn redirect_url_allowed(surface: &str, previous_url: Option<&str>, url: &str) -> bool {
match check_redirect_url(surface, previous_url, url) {
Ok(Some(blocked)) => {
audit_blocked_background(blocked);
false
}
Ok(None) => true,
Err(_) => false,
}
}
pub fn redirect_policy(surface: &'static str, max_redirects: usize) -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= max_redirects {
attempt.error("too many redirects")
} else if redirect_url_allowed(
surface,
attempt.previous().last().map(|url| url.as_str()),
attempt.url().as_str(),
) {
attempt.follow()
} else {
attempt.error("egress policy blocked redirect target")
}
})
}
pub fn redact_reqwest_error(error: &reqwest::Error) -> String {
redact_diagnostic_text(&error.to_string())
}
pub fn redact_diagnostic_text(raw: &str) -> String {
let policy = crate::redact::current_policy();
let urls_redacted = policy.redact_urls_in_text(raw);
policy.redact_string(urls_redacted.as_ref()).into_owned()
}
fn check_redirect_url(
surface: &str,
previous_url: Option<&str>,
raw_url: &str,
) -> Result<Option<EgressBlocked>, VmError> {
if let Some(blocked) = insecure_redirect_downgrade_block(surface, previous_url, raw_url)? {
return Ok(Some(blocked));
}
check_url(surface, raw_url)
}
fn insecure_redirect_downgrade_block(
surface: &str,
previous_url: Option<&str>,
raw_url: &str,
) -> Result<Option<EgressBlocked>, VmError> {
let Some(previous_url) = previous_url else {
return Ok(None);
};
let previous = Url::parse(previous_url).map_err(|error| {
vm_error(format!(
"egress: invalid redirect source `{previous_url}`: {error}"
))
})?;
if previous.scheme() != "https" {
return Ok(None);
}
let target_url = Url::parse(raw_url)
.map_err(|error| vm_error(format!("egress: invalid URL `{raw_url}`: {error}")))?;
if target_url.scheme() != "http" {
return Ok(None);
}
let target = EgressTarget::parse(raw_url)?;
if insecure_redirect_loopback_exempt(&target) {
return Ok(None);
}
Ok(Some(blocked(
surface,
raw_url,
&target,
"https redirect target downgrades to insecure http".to_string(),
)))
}
fn insecure_redirect_loopback_exempt(target: &EgressTarget) -> bool {
let (_, allow_loopback) = current_ssrf_client_settings();
allow_loopback && target.is_loopback_host()
}
pub fn client_error_for_url(surface: &str, url: &str) -> Option<crate::connectors::ClientError> {
match check_url(surface, url) {
Ok(Some(blocked)) => {
audit_blocked_background(blocked.clone());
Some(crate::connectors::ClientError::EgressBlocked(blocked))
}
Ok(None) => None,
Err(error) => Some(crate::connectors::ClientError::InvalidArgs(
error.to_string(),
)),
}
}
pub fn connector_error_for_url(
surface: &str,
url: &str,
) -> Option<crate::connectors::ConnectorError> {
match check_url(surface, url) {
Ok(Some(blocked)) => {
audit_blocked_background(blocked.clone());
Some(crate::connectors::ConnectorError::Activation(
blocked.to_string(),
))
}
Ok(None) => None,
Err(error) => Some(crate::connectors::ConnectorError::Activation(
error.to_string(),
)),
}
}
pub fn reset_egress_policy_for_host() {
with_state_write(|state| *state = EgressState::default());
clear_explicit_egress_policy_requirement_for_host();
clear_ssrf_guard_requirement_for_host();
}
fn configured_policy() -> Option<ConfiguredPolicy> {
with_state_read(|state| state.policy.clone())
}
#[must_use]
pub fn scope_egress_policy_for_current_thread() -> EgressPolicyScope {
scope_egress_policy_context_for_current_thread(Some(EgressPolicyContext(Arc::new(
RwLock::new(EgressState::default()),
))))
}
pub(crate) fn swap_policy_context(
context: Option<EgressPolicyContext>,
) -> Option<EgressPolicyContext> {
EGRESS_POLICY_CONTEXT.with(|current| std::mem::replace(&mut *current.borrow_mut(), context))
}
fn with_egress_policy_context<T>(
context: Option<EgressPolicyContext>,
run: impl FnOnce() -> T,
) -> T {
let _scope = scope_egress_policy_context_for_current_thread(context);
run()
}
pub(crate) fn bind_policy_context<T>(run: impl FnOnce() -> T + Send) -> impl FnOnce() -> T + Send {
let context = current_policy_context();
move || with_egress_policy_context(context, run)
}
#[must_use]
fn scope_egress_policy_context_for_current_thread(
context: Option<EgressPolicyContext>,
) -> EgressPolicyScope {
let previous = swap_policy_context(context);
EgressPolicyScope {
previous,
_thread_bound: PhantomData,
}
}
#[derive(Debug)]
pub struct EgressPolicyScope {
previous: Option<EgressPolicyContext>,
_thread_bound: PhantomData<Rc<()>>,
}
impl Drop for EgressPolicyScope {
fn drop(&mut self) {
EGRESS_POLICY_CONTEXT.with(|current| {
*current.borrow_mut() = self.previous.take();
});
}
}
pub(crate) fn clear_explicit_egress_policy_requirement_for_host() {
REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH.with(|depth| *depth.borrow_mut() = 0);
}
pub fn require_explicit_egress_policy_for_host() -> ExplicitEgressPolicyGuard {
REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH.with(|depth| {
*depth.borrow_mut() += 1;
});
ExplicitEgressPolicyGuard
}
#[derive(Debug)]
pub struct ExplicitEgressPolicyGuard;
impl Drop for ExplicitEgressPolicyGuard {
fn drop(&mut self) {
REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH.with(|depth| {
let mut depth = depth.borrow_mut();
*depth = depth.saturating_sub(1);
});
}
}
pub fn require_ssrf_guard_for_host() -> SsrfGuardScope {
REQUIRE_SSRF_GUARD_DEPTH.with(|depth| {
*depth.borrow_mut() += 1;
});
SsrfGuardScope
}
#[derive(Debug)]
pub struct SsrfGuardScope;
impl Drop for SsrfGuardScope {
fn drop(&mut self) {
REQUIRE_SSRF_GUARD_DEPTH.with(|depth| {
let mut depth = depth.borrow_mut();
*depth = depth.saturating_sub(1);
});
}
}
pub(crate) fn swap_require_explicit_egress_policy_depth(next: usize) -> usize {
REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH
.with(|depth| std::mem::replace(&mut *depth.borrow_mut(), next))
}
pub(crate) fn swap_require_ssrf_guard_depth(next: usize) -> usize {
REQUIRE_SSRF_GUARD_DEPTH.with(|depth| std::mem::replace(&mut *depth.borrow_mut(), next))
}
pub(crate) fn clear_ssrf_guard_requirement_for_host() {
REQUIRE_SSRF_GUARD_DEPTH.with(|depth| *depth.borrow_mut() = 0);
}
fn ssrf_guard_scope_active() -> bool {
REQUIRE_SSRF_GUARD_DEPTH.with(|depth| *depth.borrow() > 0)
}
pub fn current_ssrf_client_settings() -> (bool, bool) {
let _ = ensure_env_seeded();
let configured = configured_policy();
let (mode, allow_loopback) = effective_ssrf_settings(configured.as_ref().map(|c| &c.policy));
(mode == SsrfMode::BlockPrivate, allow_loopback)
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ResolvedIpRules {
pub deny: Vec<IpNet>,
pub allow: Vec<IpNet>,
pub allow_active: bool,
}
impl ResolvedIpRules {
pub fn is_empty(&self) -> bool {
self.deny.is_empty() && !self.allow_active
}
}
fn ip_to_net(ip: IpAddr) -> IpNet {
match ip {
IpAddr::V4(v4) => IpNet::from(ipnet::Ipv4Net::new(v4, 32).expect("/32 is valid")),
IpAddr::V6(v6) => IpNet::from(ipnet::Ipv6Net::new(v6, 128).expect("/128 is valid")),
}
}
fn resolved_ip_rules_for(policy: &EgressPolicy) -> ResolvedIpRules {
let net_of = |rule: &EgressRule| match &rule.matcher {
EgressMatcher::Cidr(net) => Some(*net),
EgressMatcher::Ip(ip) => Some(ip_to_net(*ip)),
_ => None,
};
let deny: Vec<IpNet> = policy
.deny
.iter()
.filter(|rule| rule.port.is_none())
.filter_map(net_of)
.collect();
let allow: Vec<IpNet> = policy
.allow
.iter()
.filter(|rule| rule.port.is_none())
.filter_map(net_of)
.collect();
ResolvedIpRules {
deny,
allow,
allow_active: policy.default == DefaultAction::Deny,
}
}
pub fn current_resolved_ip_rules() -> ResolvedIpRules {
let _ = ensure_env_seeded();
let configured = configured_policy();
configured
.as_ref()
.map(|c| resolved_ip_rules_for(&c.policy))
.unwrap_or_default()
}
pub fn install_ssrf_guard(builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
install_ssrf_guard_with_private_host_allowlist(builder, &[])
}
fn effective_ssrf_settings(configured: Option<&EgressPolicy>) -> (SsrfMode, bool) {
let scope_active = ssrf_guard_scope_active();
match configured {
Some(policy) => {
let mode = policy.block_private.unwrap_or(if scope_active {
SsrfMode::BlockPrivate
} else {
SsrfMode::Off
});
(mode, policy.allow_loopback)
}
None => {
let mode = if scope_active {
SsrfMode::BlockPrivate
} else {
SsrfMode::Off
};
(mode, false)
}
}
}
fn private_block_reason(host: &str) -> String {
format!(
"host `{host}` is a disallowed address \
(private, loopback, link-local, or metadata IP)"
)
}
fn private_block_for_literal(target: &EgressTarget, allow_loopback: bool) -> Option<String> {
target.ip.and_then(|ip| {
is_disallowed_ip(ip, allow_loopback).then(|| private_block_reason(&target.host))
})
}
async fn resolve_host_addrs(host: &str) -> Option<Vec<IpAddr>> {
use std::net::ToSocketAddrs;
let host = host.to_string();
tokio::task::spawn_blocking(move || {
(host.as_str(), 0_u16)
.to_socket_addrs()
.ok()
.map(|addrs| addrs.map(|addr| addr.ip()).collect::<Vec<_>>())
.filter(|addrs: &Vec<IpAddr>| !addrs.is_empty())
})
.await
.ok()
.flatten()
}
enum SyncCheck {
Allowed,
Blocked(EgressBlocked),
DeferToResolution(EgressBlocked),
}
pub(crate) fn check_url(surface: &str, raw_url: &str) -> Result<Option<EgressBlocked>, VmError> {
match check_url_decision(surface, raw_url)? {
SyncCheck::Allowed => Ok(None),
SyncCheck::Blocked(blocked) | SyncCheck::DeferToResolution(blocked) => Ok(Some(blocked)),
}
}
fn check_url_decision(surface: &str, raw_url: &str) -> Result<SyncCheck, VmError> {
ensure_env_seeded()?;
let configured = configured_policy();
let (ssrf_mode, allow_loopback) =
effective_ssrf_settings(configured.as_ref().map(|c| &c.policy));
let require_explicit_policy =
REQUIRE_EXPLICIT_EGRESS_POLICY_DEPTH.with(|depth| *depth.borrow() > 0);
let Some(configured) = configured else {
if ssrf_mode == SsrfMode::BlockPrivate {
let target = EgressTarget::parse(raw_url)?;
if let Some(reason) = private_block_for_literal(&target, allow_loopback) {
return Ok(SyncCheck::Blocked(blocked(
surface, raw_url, &target, reason,
)));
}
}
if require_explicit_policy {
let target = EgressTarget::parse(raw_url)?;
return Ok(SyncCheck::Blocked(blocked(
surface,
raw_url,
&target,
"no egress policy configured".to_string(),
)));
}
return Ok(SyncCheck::Allowed);
};
let target = EgressTarget::parse(raw_url)?;
if ssrf_mode == SsrfMode::BlockPrivate {
if let Some(reason) = private_block_for_literal(&target, allow_loopback) {
return Ok(SyncCheck::Blocked(blocked(
surface, raw_url, &target, reason,
)));
}
}
if let Some(rule) = configured
.policy
.deny
.iter()
.find(|rule| rule.matches(&target))
{
return Ok(SyncCheck::Blocked(blocked(
surface,
raw_url,
&target,
format!("matched deny rule `{}`", rule.raw),
)));
}
if configured
.policy
.allow
.iter()
.any(|rule| rule.matches(&target))
{
return Ok(SyncCheck::Allowed);
}
if configured.policy.default == DefaultAction::Allow {
return Ok(SyncCheck::Allowed);
}
let could_allow_on_resolution = target.ip.is_none()
&& configured
.policy
.allow
.iter()
.any(EgressRule::is_ip_matcher);
let blocked = blocked(
surface,
raw_url,
&target,
"no allow rule matched".to_string(),
);
if could_allow_on_resolution {
Ok(SyncCheck::DeferToResolution(blocked))
} else {
Ok(SyncCheck::Blocked(blocked))
}
}
async fn check_url_host_resolution(
surface: &str,
raw_url: &str,
resolution_required: bool,
) -> Result<Option<EgressBlocked>, VmError> {
let configured = configured_policy();
let (ssrf_mode, _allow_loopback) =
effective_ssrf_settings(configured.as_ref().map(|c| &c.policy));
let block_private = ssrf_mode == SsrfMode::BlockPrivate;
let has_ip_deny = configured
.as_ref()
.is_some_and(|c| c.policy.deny.iter().any(EgressRule::is_ip_matcher));
if !block_private && !has_ip_deny && !resolution_required {
return Ok(None);
}
let target = EgressTarget::parse(raw_url)?;
if target.ip.is_some() {
return Ok(None);
}
let Some(addrs) = resolve_host_addrs(&target.host).await else {
if resolution_required {
return Ok(Some(blocked(
surface,
raw_url,
&target,
"no allow rule matched".to_string(),
)));
}
return Ok(None);
};
Ok(evaluate_resolved_addrs(
configured.as_ref(),
surface,
raw_url,
&target,
&addrs,
resolution_required,
))
}
fn evaluate_resolved_addrs(
configured: Option<&ConfiguredPolicy>,
surface: &str,
raw_url: &str,
target: &EgressTarget,
addrs: &[IpAddr],
resolution_required: bool,
) -> Option<EgressBlocked> {
let (ssrf_mode, allow_loopback) = effective_ssrf_settings(configured.map(|c| &c.policy));
let block_private = ssrf_mode == SsrfMode::BlockPrivate;
let allow_active = configured.is_some_and(|c| c.policy.default == DefaultAction::Deny);
if block_private && addrs.iter().any(|ip| is_disallowed_ip(*ip, allow_loopback)) {
return Some(blocked(
surface,
raw_url,
target,
private_block_reason(&target.host),
));
}
if let Some(rule) = configured.and_then(|c| {
c.policy.deny.iter().find(|rule| {
rule.is_ip_matcher()
&& addrs
.iter()
.any(|ip| rule.matches_resolved_ip(*ip, target.port))
})
}) {
return Some(blocked(
surface,
raw_url,
target,
format!("resolved address matched deny rule `{}`", rule.raw),
));
}
if resolution_required && allow_active {
let permitted = configured.is_some_and(|c| {
!addrs.is_empty()
&& addrs.iter().all(|ip| {
c.policy.allow.iter().any(|rule| {
rule.is_ip_matcher() && rule.matches_resolved_ip(*ip, target.port)
})
})
});
if !permitted {
return Some(blocked(
surface,
raw_url,
target,
"no allow rule matched".to_string(),
));
}
}
None
}
fn blocked(surface: &str, url: &str, target: &EgressTarget, reason: String) -> EgressBlocked {
EgressBlocked {
surface: surface.to_string(),
url: redact_sensitive_url(url),
host: target.host.clone(),
port: target.port,
reason,
}
}
async fn audit_blocked(blocked: &EgressBlocked) {
let Some(log) = active_event_log() else {
return;
};
let Ok(topic) = Topic::new(EGRESS_AUDIT_TOPIC) else {
return;
};
let payload = json!({
"surface": blocked.surface,
"url": blocked.url,
"host": blocked.host,
"port": blocked.port,
"reason": blocked.reason,
"error_type": "EgressBlocked",
});
let _ = log
.append(&topic, LogEvent::new("egress.blocked", payload))
.await;
}
fn audit_blocked_background(blocked: EgressBlocked) {
let Some(log) = active_event_log() else {
return;
};
let Ok(topic) = Topic::new(EGRESS_AUDIT_TOPIC) else {
return;
};
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let payload = json!({
"surface": blocked.surface,
"url": blocked.url,
"host": blocked.host,
"port": blocked.port,
"reason": blocked.reason,
"error_type": "EgressBlocked",
});
let _ = log
.append(&topic, LogEvent::new("egress.blocked", payload))
.await;
});
}
}
fn install_policy(
policy: EgressPolicy,
declared: DeclaredAxes,
source: &'static str,
) -> Result<(), VmError> {
ensure_env_seeded()?;
with_state_write(|state| {
match &mut state.policy {
Some(existing) => existing.compose(policy, declared, source),
slot => *slot = Some(ConfiguredPolicy::first(policy, declared, source)),
}
Ok(())
})
}
pub(crate) fn install_deny_by_default_policy(allow: &[String]) -> Result<(), VmError> {
let policy = EgressPolicy {
allow: parse_rule_list(&allow.join(","))?,
deny: Vec::new(),
default: DefaultAction::Deny,
block_private: None,
allow_loopback: false,
};
reset_egress_policy_for_host();
let configured = ConfiguredPolicy::first(
policy,
DeclaredAxes {
allow: true,
deny: true,
default: true,
..DeclaredAxes::default()
},
"testbench",
);
with_state_write(|state| {
state.env_checked = true;
state.policy = Some(configured);
});
Ok(())
}
fn ensure_env_seeded() -> Result<(), VmError> {
if with_state_read(|state| state.env_checked) {
return Ok(());
}
use crate::test_env::env_var_seamed;
let allow = env_var_seamed(HARN_EGRESS_ALLOW_ENV);
let deny = env_var_seamed(HARN_EGRESS_DENY_ENV);
let default = env_var_seamed(HARN_EGRESS_DEFAULT_ENV);
let block_private = env_var_seamed(HARN_EGRESS_BLOCK_PRIVATE_ENV);
let allow_loopback = env_var_seamed(HARN_EGRESS_ALLOW_LOOPBACK_ENV);
let any_set = allow.is_some()
|| deny.is_some()
|| default.is_some()
|| block_private.is_some()
|| allow_loopback.is_some();
let build_policy = || -> Result<EgressPolicy, VmError> {
Ok(EgressPolicy {
allow: parse_rule_list(allow.as_deref().unwrap_or(""))?,
deny: parse_rule_list(deny.as_deref().unwrap_or(""))?,
default: parse_default_action(default.as_deref().unwrap_or("allow"))?,
block_private: block_private.as_deref().map(parse_ssrf_mode).transpose()?,
allow_loopback: allow_loopback
.as_deref()
.map(parse_bool)
.transpose()?
.unwrap_or(false),
})
};
with_state_write(|state| {
if state.env_checked {
return Ok(());
}
state.env_checked = true;
if !any_set {
return Ok(());
}
let declared = DeclaredAxes {
allow: allow.is_some(),
deny: deny.is_some(),
default: default.is_some(),
block_private: block_private.is_some(),
allow_loopback: allow_loopback.is_some(),
};
state.policy = Some(ConfiguredPolicy::first(
build_policy()?,
declared,
"environment",
));
Ok(())
})
}
fn policy_from_config(
config: &crate::value::DictMap,
) -> Result<(EgressPolicy, DeclaredAxes), VmError> {
let allow = match config.get("allow") {
Some(VmValue::List(items)) => parse_rule_values(items)?,
Some(VmValue::Nil) => Vec::new(),
Some(_) => return Err(vm_error("egress_policy: allow must be a list")),
None => Vec::new(),
};
let deny = match config.get("deny") {
Some(VmValue::List(items)) => parse_rule_values(items)?,
Some(VmValue::Nil) => Vec::new(),
Some(_) => return Err(vm_error("egress_policy: deny must be a list")),
None => Vec::new(),
};
let default = config
.get("default")
.map(|value| parse_default_action(&value.display()))
.transpose()?
.unwrap_or(DefaultAction::Allow);
let block_private = config
.get("block_private")
.map(|value| parse_ssrf_mode(&value.display()))
.transpose()?;
let allow_loopback = match config.get("allow_loopback") {
Some(value) => parse_bool(&value.display())?,
None => false,
};
let declared = DeclaredAxes {
allow: config.contains_key("allow"),
deny: config.contains_key("deny"),
default: config.contains_key("default"),
block_private: config.contains_key("block_private"),
allow_loopback: config.contains_key("allow_loopback"),
};
Ok((
EgressPolicy {
allow,
deny,
default,
block_private,
allow_loopback,
},
declared,
))
}
fn source_list(sources: &[&'static str]) -> VmValue {
VmValue::List(std::sync::Arc::new(
sources
.iter()
.map(|source| VmValue::String(arcstr::ArcStr::from(*source)))
.collect(),
))
}
fn policy_summary() -> VmValue {
let configured = configured_policy();
let mut dict = BTreeMap::new();
if let Some(configured) = configured {
dict.insert("configured".to_string(), VmValue::Bool(true));
dict.put_str(
"source",
configured.sources.last().copied().unwrap_or("stdlib"),
);
dict.insert("sources".to_string(), source_list(&configured.sources));
let mut axis_sources = BTreeMap::new();
axis_sources.insert(
"allow".to_string(),
source_list(&configured.axis_sources.allow),
);
axis_sources.insert(
"deny".to_string(),
source_list(&configured.axis_sources.deny),
);
axis_sources.insert(
"default".to_string(),
source_list(&configured.axis_sources.default),
);
axis_sources.insert(
"block_private".to_string(),
source_list(&configured.axis_sources.block_private),
);
axis_sources.insert(
"allow_loopback".to_string(),
source_list(&configured.axis_sources.allow_loopback),
);
dict.insert("axis_sources".to_string(), VmValue::dict(axis_sources));
dict.put_str(
"default",
match configured.policy.default {
DefaultAction::Allow => "allow",
DefaultAction::Deny => "deny",
},
);
dict.insert(
"allow".to_string(),
VmValue::List(std::sync::Arc::new(
configured
.policy
.allow
.iter()
.map(|rule| VmValue::String(arcstr::ArcStr::from(rule.raw.as_str())))
.collect(),
)),
);
dict.insert(
"deny".to_string(),
VmValue::List(std::sync::Arc::new(
configured
.policy
.deny
.iter()
.map(|rule| VmValue::String(arcstr::ArcStr::from(rule.raw.as_str())))
.collect(),
)),
);
let (mode, allow_loopback) = effective_ssrf_settings(Some(&configured.policy));
dict.put_str(
"block_private",
match mode {
SsrfMode::BlockPrivate => "private",
SsrfMode::Off => "off",
},
);
dict.insert("allow_loopback".to_string(), VmValue::Bool(allow_loopback));
} else {
dict.insert("configured".to_string(), VmValue::Bool(false));
}
VmValue::dict(dict)
}
fn redact_sensitive_url(url: &str) -> String {
crate::redact::current_policy().redact_url(url)
}
fn vm_error(message: impl Into<String>) -> VmError {
VmError::Thrown(VmValue::String(arcstr::ArcStr::from(message.into())))
}
impl EgressBlocked {
pub(crate) fn to_vm_error(&self) -> VmError {
let mut dict = BTreeMap::new();
dict.put_str("type", "EgressBlocked");
dict.put_str("category", "egress_blocked");
dict.put_str("message", self.to_string());
dict.put_str("surface", self.surface.as_str());
dict.put_str("url", self.url.as_str());
dict.put_str("host", self.host.as_str());
dict.insert(
"port".to_string(),
self.port
.map(|port| VmValue::Int(port as i64))
.unwrap_or(VmValue::Nil),
);
dict.put_str("reason", self.reason.as_str());
VmError::Thrown(VmValue::dict(dict))
}
}
impl std::fmt::Display for EgressBlocked {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.port {
Some(port) => write!(
f,
"EgressBlocked: {} blocked {}:{} for {} ({})",
self.surface, self.host, port, self.url, self.reason
),
None => write!(
f,
"EgressBlocked: {} blocked {} for {} ({})",
self.surface, self.host, self.url, self.reason
),
}
}
}
#[cfg(test)]
mod tests;