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 provider_allow;
pub mod ssrf;
#[cfg(test)]
pub(crate) mod test_support;
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)]
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)]
struct EgressRule {
raw: String,
matcher: EgressMatcher,
port: Option<u16>,
}
#[derive(Clone, Debug)]
enum EgressMatcher {
Host(String),
Suffix(String),
Ip(IpAddr),
Cidr(IpNet),
}
#[derive(Clone, Debug, Default)]
struct EgressState {
env_checked: bool,
policy: Option<ConfiguredPolicy>,
}
#[derive(Clone, Debug)]
struct ConfiguredPolicy {
source: &'static str,
policy: EgressPolicy,
}
#[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_builtin("egress_policy", |args, _out| {
let Some(VmValue::Dict(config)) = args.first() else {
return Err(vm_error("egress_policy: requires a config dict"));
};
let policy = policy_from_config(config)?;
install_policy(policy, "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);
}
#[cfg(test)]
pub fn reset_egress_policy_for_tests() {
reset_egress_policy_for_host();
}
#[cfg(test)]
#[must_use]
pub(crate) fn test_env_guard() -> EgressTestEnvGuard {
let inner = crate::test_env::test_env_guard();
reset_egress_policy_for_host();
EgressTestEnvGuard { inner }
}
#[cfg(test)]
pub(crate) struct EgressTestEnvGuard {
inner: crate::test_env::TestEnvGuard,
}
#[cfg(test)]
impl EgressTestEnvGuard {
pub(crate) fn set(&self, key: &str, value: &str) {
self.inner.set(key, value);
}
}
#[cfg(test)]
impl Drop for EgressTestEnvGuard {
fn drop(&mut self) {
reset_egress_policy_for_host();
}
}
#[cfg(test)]
pub(crate) struct EgressTestConfigGuard {
_env: EgressTestEnvGuard,
}
#[cfg(test)]
impl EgressTestConfigGuard {
pub(crate) fn new() -> Self {
Self {
_env: test_env_guard(),
}
}
}
#[cfg(test)]
pub(crate) fn install_test_policy(config: &[(&str, VmValue)]) {
let map = config
.iter()
.cloned()
.map(|(key, value)| (key.to_string(), value))
.collect();
let policy = policy_from_config(&map).expect("test egress policy parses");
install_policy(policy, "test").expect("test egress policy installs");
}
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 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| {
c.policy.allow.iter().any(|rule| {
rule.is_ip_matcher()
&& addrs
.iter()
.any(|ip| 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, source: &'static str) -> Result<(), VmError> {
ensure_env_seeded()?;
with_state_write(|state| {
if let Some(existing) = &state.policy {
return Err(vm_error(format!(
"egress_policy: policy already configured from {}",
existing.source
)));
}
state.policy = Some(ConfiguredPolicy { source, policy });
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 {
source: "testbench",
policy,
};
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(());
}
state.policy = Some(ConfiguredPolicy {
source: "environment",
policy: build_policy()?,
});
Ok(())
})
}
fn policy_from_config(config: &crate::value::DictMap) -> Result<EgressPolicy, 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,
};
Ok(EgressPolicy {
allow,
deny,
default,
block_private,
allow_loopback,
})
}
fn parse_ssrf_mode(raw: &str) -> Result<SsrfMode, VmError> {
match raw.trim().to_ascii_lowercase().as_str() {
"private" | "on" | "block" | "block_private" | "true" => Ok(SsrfMode::BlockPrivate),
"off" | "false" | "none" => Ok(SsrfMode::Off),
other => Err(vm_error(format!(
"egress_policy: block_private must be `private`/`on` or `off`, got `{other}`"
))),
}
}
fn parse_bool(raw: &str) -> Result<bool, VmError> {
match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Ok(true),
"false" | "0" | "no" | "off" | "" => Ok(false),
other => Err(vm_error(format!(
"egress_policy: allow_loopback must be a boolean, got `{other}`"
))),
}
}
fn parse_rule_values(values: &[VmValue]) -> Result<Vec<EgressRule>, VmError> {
values
.iter()
.map(|value| EgressRule::parse(&value.display()))
.collect()
}
fn parse_rule_list(raw: &str) -> Result<Vec<EgressRule>, VmError> {
raw.split([',', '\n', ';'])
.map(str::trim)
.filter(|part| !part.is_empty())
.map(EgressRule::parse)
.collect()
}
fn parse_default_action(raw: &str) -> Result<DefaultAction, VmError> {
match raw.trim().to_ascii_lowercase().as_str() {
"" | "allow" => Ok(DefaultAction::Allow),
"deny" => Ok(DefaultAction::Deny),
other => Err(vm_error(format!(
"egress_policy: default must be `allow` or `deny`, got `{other}`"
))),
}
}
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.source);
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)
}
impl EgressRule {
fn parse(raw: &str) -> Result<Self, VmError> {
let raw = raw.trim();
if raw.is_empty() {
return Err(vm_error("egress_policy: empty egress rule"));
}
let (host, port) = parse_rule_host_port(raw)?;
let host = normalize_host(&host);
let matcher = if let Some(suffix) = host.strip_prefix("*.") {
if suffix.is_empty() {
return Err(vm_error(format!(
"egress_policy: invalid wildcard rule `{raw}`"
)));
}
EgressMatcher::Suffix(suffix.to_string())
} else if host.contains('/') {
EgressMatcher::Cidr(IpNet::from_str(&host).map_err(|error| {
vm_error(format!("egress_policy: invalid CIDR rule `{raw}`: {error}"))
})?)
} else if let Ok(ip) = IpAddr::from_str(&host) {
EgressMatcher::Ip(ip)
} else {
EgressMatcher::Host(host)
};
Ok(Self {
raw: raw.to_string(),
matcher,
port,
})
}
fn matches(&self, target: &EgressTarget) -> bool {
if let Some(port) = self.port {
if target.port != Some(port) {
return false;
}
}
match &self.matcher {
EgressMatcher::Host(host) => target.host == *host,
EgressMatcher::Suffix(suffix) => {
crate::harness_net::host_has_dns_suffix(&target.host, suffix)
}
EgressMatcher::Ip(ip) => target.ip == Some(*ip),
EgressMatcher::Cidr(net) => target.ip.is_some_and(|ip| net.contains(&ip)),
}
}
fn is_ip_matcher(&self) -> bool {
matches!(self.matcher, EgressMatcher::Ip(_) | EgressMatcher::Cidr(_))
}
fn matches_resolved_ip(&self, ip: IpAddr, request_port: Option<u16>) -> bool {
if let Some(port) = self.port {
if request_port != Some(port) {
return false;
}
}
match &self.matcher {
EgressMatcher::Ip(rule_ip) => *rule_ip == ip,
EgressMatcher::Cidr(net) => net.contains(&ip),
EgressMatcher::Host(_) | EgressMatcher::Suffix(_) => false,
}
}
}
#[derive(Clone, Debug)]
struct EgressTarget {
host: String,
ip: Option<IpAddr>,
port: Option<u16>,
}
impl EgressTarget {
fn parse(raw_url: &str) -> Result<Self, VmError> {
let parsed = Url::parse(raw_url)
.map_err(|error| vm_error(format!("egress: invalid URL `{raw_url}`: {error}")))?;
let host = parsed
.host_str()
.ok_or_else(|| vm_error(format!("egress: URL `{raw_url}` does not include a host")))?;
let host = normalize_host(host);
let ip = IpAddr::from_str(&host).ok();
Ok(Self {
host,
ip,
port: parsed.port_or_known_default(),
})
}
fn is_loopback_host(&self) -> bool {
self.host == "localhost"
|| self.ip.is_some_and(|ip| match ip {
IpAddr::V4(v4) => v4.is_loopback(),
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6
.to_ipv4_mapped()
.is_some_and(|mapped| mapped.is_loopback())
}
})
}
}
fn parse_rule_host_port(raw: &str) -> Result<(String, Option<u16>), VmError> {
if let Ok(url) = Url::parse(raw) {
if let Some(host) = url.host_str() {
return Ok((host.to_string(), url.port_or_known_default()));
}
}
let raw = raw.trim();
if let Some(rest) = raw.strip_prefix('[') {
let Some((host, suffix)) = rest.split_once(']') else {
return Err(vm_error(format!(
"egress_policy: invalid bracketed host rule `{raw}`"
)));
};
let port = if let Some(port) = suffix.strip_prefix(':') {
Some(parse_port(raw, port)?)
} else if suffix.is_empty() {
None
} else {
return Err(vm_error(format!(
"egress_policy: invalid bracketed host rule `{raw}`"
)));
};
return Ok((host.to_string(), port));
}
if let Some((host, port)) = split_host_port(raw) {
return Ok((host.to_string(), Some(parse_port(raw, port)?)));
}
Ok((raw.to_string(), None))
}
fn split_host_port(raw: &str) -> Option<(&str, &str)> {
let (host, port) = raw.rsplit_once(':')?;
if host.contains(':') || port.is_empty() || !port.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
Some((host, port))
}
fn parse_port(rule: &str, raw: &str) -> Result<u16, VmError> {
raw.parse::<u16>()
.map_err(|error| vm_error(format!("egress_policy: invalid port in `{rule}`: {error}")))
}
fn normalize_host(host: &str) -> String {
host.trim()
.trim_end_matches('.')
.trim_matches('[')
.trim_matches(']')
.to_ascii_lowercase()
}
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;