From c36faacc9750bb8098e7a02ba4fa71eff61653a1 Mon Sep 17 00:00:00 2001 From: mjamiv Date: Mon, 25 May 2026 21:56:02 +0000 Subject: [PATCH] test(sandbox): reject malformed proxy hostnames --- crates/openshell-sandbox/src/opa.rs | 91 ++++++++++++++++++++++++++- crates/openshell-sandbox/src/proxy.rs | 30 ++++++++- 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index b49875b78..45142c12b 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -52,6 +52,13 @@ pub struct NetworkInput { pub cmdline_paths: Vec, } +pub(crate) fn network_host_is_safe(host: &str) -> bool { + !host.is_empty() + && !host.chars().any(|ch| { + ch.is_ascii_control() || ch.is_ascii_whitespace() || matches!(ch, '%' | '/' | '\\') + }) +} + /// Sandbox configuration extracted from OPA data at startup. pub struct SandboxConfig { pub filesystem: FilesystemPolicy, @@ -239,6 +246,14 @@ impl OpaEngine { /// `allow_network` rule, and returns a `PolicyDecision` with the result, /// deny reason, and matched policy name. pub fn evaluate_network(&self, input: &NetworkInput) -> Result { + if !network_host_is_safe(&input.host) { + return Ok(PolicyDecision { + allowed: false, + reason: "invalid network host".to_string(), + matched_policy: None, + }); + } + let ancestor_strs: Vec = input .ancestors .iter() @@ -309,6 +324,16 @@ impl OpaEngine { &self, input: &NetworkInput, ) -> Result<(NetworkAction, u64)> { + let generation = self.current_generation(); + if !network_host_is_safe(&input.host) { + return Ok(( + NetworkAction::Deny { + reason: "invalid network host".to_string(), + }, + generation, + )); + } + let ancestor_strs: Vec = input .ancestors .iter() @@ -335,7 +360,6 @@ impl OpaEngine { .engine .lock() .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; - let generation = self.current_generation(); engine .set_input_json(&input_json.to_string()) @@ -3966,6 +3990,71 @@ network_policies: ); } + #[test] + fn wildcard_host_rejects_malformed_input_hosts() { + let data = r#" +network_policies: + wildcard: + name: wildcard + endpoints: + - { host: "*.example.com", port: 443 } + binaries: + - { path: /usr/bin/curl } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + + for host in [ + "api%00.example.com", + "api%2eexample.com", + "api.example.com\u{0}", + "api.example.com\t", + "api.example.com/path", + "api.example.com\\path", + ] { + let input = NetworkInput { + host: host.into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&input).unwrap(); + assert!(!decision.allowed, "malformed host {host:?} must deny"); + assert_eq!(decision.reason, "invalid network host"); + assert_eq!(decision.matched_policy, None); + } + } + + #[test] + fn network_action_rejects_malformed_input_host_before_policy_allow() { + let data = r#" +network_policies: + wildcard: + name: wildcard + endpoints: + - { host: "*.example.com", port: 443 } + binaries: + - { path: /usr/bin/curl } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let input = NetworkInput { + host: "api%00.example.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + assert_eq!( + engine.evaluate_network_action(&input).unwrap(), + NetworkAction::Deny { + reason: "invalid network host".to_string() + } + ); + } + #[test] fn wildcard_host_plus_port() { let data = r#" diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index 037ecfc78..e60f9cbb4 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -6,7 +6,7 @@ use crate::denial_aggregator::DenialEvent; use crate::identity::BinaryIdentityCache; use crate::l7::tls::ProxyTlsState; -use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard}; +use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard, network_host_is_safe}; use crate::policy::ProxyPolicy; use crate::policy_local::{POLICY_LOCAL_HOST, PolicyLocalContext}; use crate::provider_credentials::ProviderCredentialState; @@ -3618,6 +3618,9 @@ fn parse_target(target: &str) -> Result<(String, u16)> { let (host, port_str) = target .split_once(':') .ok_or_else(|| miette::miette!("CONNECT target missing port: {target}"))?; + if !network_host_is_safe(host) { + return Err(miette::miette!("Invalid host in CONNECT target: {target}")); + } let port: u16 = port_str .parse() .map_err(|_| miette::miette!("Invalid port in CONNECT target: {target}"))?; @@ -5258,6 +5261,31 @@ network_policies: assert!(!msg.contains("/etc/openshell")); } + #[test] + fn parse_target_accepts_plain_authority() { + let (host, port) = parse_target("api.example.com:443").expect("parse CONNECT target"); + assert_eq!(host, "api.example.com"); + assert_eq!(port, 443); + } + + #[test] + fn parse_target_rejects_malformed_hostname_differentials() { + for target in [ + "api%00.example.com:443", + "api%2eexample.com:443", + "api.example.com\u{0}:443", + "api.example.com\t:443", + "api.example.com/path:443", + "api.example.com\\path:443", + ] { + let err = parse_target(target).expect_err("malformed CONNECT host must be rejected"); + assert!( + format!("{err}").contains("Invalid host"), + "unexpected error for {target:?}: {err}" + ); + } + } + #[test] fn sanitize_response_headers_strips_hop_by_hop() { let headers = vec![