## Summary When network requests were blocked, downstream code often had to infer ask vs deny from free-form response text. That was brittle and led to incorrect approval behavior. This PR fixes the proxy side so blocked decisions are structured and request metadata survives reliably. ## Description - Blocked proxy responses now carry consistent structured policy decision data. - Request attempt metadata is preserved across proxy env paths (including ALL_PROXY flows). - Header stripping was tightened so we still remove unsafe forwarding headers, but keep metadata needed for policy handling. - Block messages were clarified (for example, allowlist miss vs explicit deny). - Added unified violation log entries so policy failures can be inspected in one place. - Added/updated tests for these behaviors. --------- Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
294 lines
9.4 KiB
Rust
294 lines
9.4 KiB
Rust
use crate::config::NetworkMode;
|
|
use crate::config::NetworkProxyConfig;
|
|
use crate::policy::DomainPattern;
|
|
use crate::policy::compile_globset;
|
|
use crate::runtime::ConfigState;
|
|
use serde::Deserialize;
|
|
use std::collections::HashSet;
|
|
|
|
pub use crate::runtime::BlockedRequest;
|
|
pub use crate::runtime::BlockedRequestArgs;
|
|
pub use crate::runtime::NetworkProxyState;
|
|
#[cfg(test)]
|
|
pub(crate) use crate::runtime::network_proxy_state_for_policy;
|
|
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
|
pub struct NetworkProxyConstraints {
|
|
pub enabled: Option<bool>,
|
|
pub mode: Option<NetworkMode>,
|
|
pub allow_upstream_proxy: Option<bool>,
|
|
pub dangerously_allow_non_loopback_proxy: Option<bool>,
|
|
pub dangerously_allow_non_loopback_admin: Option<bool>,
|
|
pub allowed_domains: Option<Vec<String>>,
|
|
pub denied_domains: Option<Vec<String>>,
|
|
pub allow_unix_sockets: Option<Vec<String>>,
|
|
pub allow_local_binding: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct PartialNetworkProxyConfig {
|
|
#[serde(default)]
|
|
pub network: PartialNetworkConfig,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Deserialize)]
|
|
pub struct PartialNetworkConfig {
|
|
pub enabled: Option<bool>,
|
|
pub mode: Option<NetworkMode>,
|
|
pub allow_upstream_proxy: Option<bool>,
|
|
pub dangerously_allow_non_loopback_proxy: Option<bool>,
|
|
pub dangerously_allow_non_loopback_admin: Option<bool>,
|
|
#[serde(default)]
|
|
pub allowed_domains: Option<Vec<String>>,
|
|
#[serde(default)]
|
|
pub denied_domains: Option<Vec<String>>,
|
|
#[serde(default)]
|
|
pub allow_unix_sockets: Option<Vec<String>>,
|
|
#[serde(default)]
|
|
pub allow_local_binding: Option<bool>,
|
|
}
|
|
|
|
pub fn build_config_state(
|
|
config: NetworkProxyConfig,
|
|
constraints: NetworkProxyConstraints,
|
|
) -> anyhow::Result<ConfigState> {
|
|
let deny_set = compile_globset(&config.network.denied_domains)?;
|
|
let allow_set = compile_globset(&config.network.allowed_domains)?;
|
|
Ok(ConfigState {
|
|
config,
|
|
allow_set,
|
|
deny_set,
|
|
constraints,
|
|
blocked: std::collections::VecDeque::new(),
|
|
blocked_total: 0,
|
|
})
|
|
}
|
|
|
|
pub fn validate_policy_against_constraints(
|
|
config: &NetworkProxyConfig,
|
|
constraints: &NetworkProxyConstraints,
|
|
) -> Result<(), NetworkProxyConstraintError> {
|
|
fn invalid_value(
|
|
field_name: &'static str,
|
|
candidate: impl Into<String>,
|
|
allowed: impl Into<String>,
|
|
) -> NetworkProxyConstraintError {
|
|
NetworkProxyConstraintError::InvalidValue {
|
|
field_name,
|
|
candidate: candidate.into(),
|
|
allowed: allowed.into(),
|
|
}
|
|
}
|
|
|
|
fn validate<T>(
|
|
candidate: T,
|
|
validator: impl FnOnce(&T) -> Result<(), NetworkProxyConstraintError>,
|
|
) -> Result<(), NetworkProxyConstraintError> {
|
|
validator(&candidate)
|
|
}
|
|
|
|
let enabled = config.network.enabled;
|
|
if let Some(max_enabled) = constraints.enabled {
|
|
validate(enabled, move |candidate| {
|
|
if *candidate && !max_enabled {
|
|
Err(invalid_value(
|
|
"network.enabled",
|
|
"true",
|
|
"false (disabled by managed config)",
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
})?;
|
|
}
|
|
|
|
if let Some(max_mode) = constraints.mode {
|
|
validate(config.network.mode, move |candidate| {
|
|
if network_mode_rank(*candidate) > network_mode_rank(max_mode) {
|
|
Err(invalid_value(
|
|
"network.mode",
|
|
format!("{candidate:?}"),
|
|
format!("{max_mode:?} or more restrictive"),
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
})?;
|
|
}
|
|
|
|
let allow_upstream_proxy = constraints.allow_upstream_proxy;
|
|
validate(
|
|
config.network.allow_upstream_proxy,
|
|
move |candidate| match allow_upstream_proxy {
|
|
Some(true) | None => Ok(()),
|
|
Some(false) => {
|
|
if *candidate {
|
|
Err(invalid_value(
|
|
"network.allow_upstream_proxy",
|
|
"true",
|
|
"false (disabled by managed config)",
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
},
|
|
)?;
|
|
|
|
let allow_non_loopback_admin = constraints.dangerously_allow_non_loopback_admin;
|
|
validate(
|
|
config.network.dangerously_allow_non_loopback_admin,
|
|
move |candidate| match allow_non_loopback_admin {
|
|
Some(true) | None => Ok(()),
|
|
Some(false) => {
|
|
if *candidate {
|
|
Err(invalid_value(
|
|
"network.dangerously_allow_non_loopback_admin",
|
|
"true",
|
|
"false (disabled by managed config)",
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
},
|
|
)?;
|
|
|
|
let allow_non_loopback_proxy = constraints.dangerously_allow_non_loopback_proxy;
|
|
validate(
|
|
config.network.dangerously_allow_non_loopback_proxy,
|
|
move |candidate| match allow_non_loopback_proxy {
|
|
Some(true) | None => Ok(()),
|
|
Some(false) => {
|
|
if *candidate {
|
|
Err(invalid_value(
|
|
"network.dangerously_allow_non_loopback_proxy",
|
|
"true",
|
|
"false (disabled by managed config)",
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
},
|
|
)?;
|
|
|
|
if let Some(allow_local_binding) = constraints.allow_local_binding {
|
|
validate(config.network.allow_local_binding, move |candidate| {
|
|
if *candidate && !allow_local_binding {
|
|
Err(invalid_value(
|
|
"network.allow_local_binding",
|
|
"true",
|
|
"false (disabled by managed config)",
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
})?;
|
|
}
|
|
|
|
if let Some(allowed_domains) = &constraints.allowed_domains {
|
|
let managed_patterns: Vec<DomainPattern> = allowed_domains
|
|
.iter()
|
|
.map(|entry| DomainPattern::parse_for_constraints(entry))
|
|
.collect();
|
|
validate(config.network.allowed_domains.clone(), move |candidate| {
|
|
let mut invalid = Vec::new();
|
|
for entry in candidate {
|
|
let candidate_pattern = DomainPattern::parse_for_constraints(entry);
|
|
if !managed_patterns
|
|
.iter()
|
|
.any(|managed| managed.allows(&candidate_pattern))
|
|
{
|
|
invalid.push(entry.clone());
|
|
}
|
|
}
|
|
if invalid.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
Err(invalid_value(
|
|
"network.allowed_domains",
|
|
format!("{invalid:?}"),
|
|
"subset of managed allowed_domains",
|
|
))
|
|
}
|
|
})?;
|
|
}
|
|
|
|
if let Some(denied_domains) = &constraints.denied_domains {
|
|
let required_set: HashSet<String> = denied_domains
|
|
.iter()
|
|
.map(|s| s.to_ascii_lowercase())
|
|
.collect();
|
|
validate(config.network.denied_domains.clone(), move |candidate| {
|
|
let candidate_set: HashSet<String> =
|
|
candidate.iter().map(|s| s.to_ascii_lowercase()).collect();
|
|
let missing: Vec<String> = required_set
|
|
.iter()
|
|
.filter(|entry| !candidate_set.contains(*entry))
|
|
.cloned()
|
|
.collect();
|
|
if missing.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
Err(invalid_value(
|
|
"network.denied_domains",
|
|
"missing managed denied_domains entries",
|
|
format!("{missing:?}"),
|
|
))
|
|
}
|
|
})?;
|
|
}
|
|
|
|
if let Some(allow_unix_sockets) = &constraints.allow_unix_sockets {
|
|
let allowed_set: HashSet<String> = allow_unix_sockets
|
|
.iter()
|
|
.map(|s| s.to_ascii_lowercase())
|
|
.collect();
|
|
validate(
|
|
config.network.allow_unix_sockets.clone(),
|
|
move |candidate| {
|
|
let mut invalid = Vec::new();
|
|
for entry in candidate {
|
|
if !allowed_set.contains(&entry.to_ascii_lowercase()) {
|
|
invalid.push(entry.clone());
|
|
}
|
|
}
|
|
if invalid.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
Err(invalid_value(
|
|
"network.allow_unix_sockets",
|
|
format!("{invalid:?}"),
|
|
"subset of managed allow_unix_sockets",
|
|
))
|
|
}
|
|
},
|
|
)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
pub enum NetworkProxyConstraintError {
|
|
#[error("invalid value for {field_name}: {candidate} (allowed {allowed})")]
|
|
InvalidValue {
|
|
field_name: &'static str,
|
|
candidate: String,
|
|
allowed: String,
|
|
},
|
|
}
|
|
|
|
impl NetworkProxyConstraintError {
|
|
pub fn into_anyhow(self) -> anyhow::Error {
|
|
anyhow::anyhow!(self)
|
|
}
|
|
}
|
|
|
|
fn network_mode_rank(mode: NetworkMode) -> u8 {
|
|
match mode {
|
|
NetworkMode::Limited => 0,
|
|
NetworkMode::Full => 1,
|
|
}
|
|
}
|