Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions crates/openshell-supervisor-network/src/opa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1575,6 +1575,40 @@ mod tests {
assert!(!decision.allowed);
}

// -- wildcard host: malformed hostname regression tests --

#[test]
fn wildcard_host_nul_byte_causes_opa_error() {
let engine = wildcard_host_engine();
let result = engine.evaluate_network(&wildcard_input("sub\0.example.com"));
assert!(
result.is_err(),
"NUL byte is an internal glob placeholder — OPA rejects it (fail closed)"
);
}

#[test]
fn wildcard_host_nul_byte_extra_label_causes_opa_error() {
let engine = wildcard_host_engine();
let result = engine.evaluate_network(&wildcard_input("evil.com\0.example.com"));
assert!(
result.is_err(),
"NUL byte in hostname causes OPA evaluation failure (fail closed)"
);
}

#[test]
fn wildcard_host_percent_encoded_dot_no_match() {
let engine = wildcard_host_engine();
let decision = engine
.evaluate_network(&wildcard_input("evil%2eexample.com"))
.unwrap();
assert!(
!decision.allowed,
"percent-encoded dot should not be decoded by OPA glob"
);
}

#[test]
fn query_sandbox_config_extracts_filesystem() {
let engine = test_engine();
Expand Down Expand Up @@ -6444,4 +6478,33 @@ network_policies:
let input = l7_input("h.test", 80, "HEAD", "/protected");
assert!(!eval_l7(&engine, &input));
}

// ---------------------------------------------------------------------------
// Test Utilities
// ---------------------------------------------------------------------------

fn wildcard_host_engine() -> OpaEngine {
let data = r#"
network_policies:
wildcard_test:
name: wildcard_test
endpoints:
- host: "*.example.com"
port: 443
binaries:
- path: /usr/bin/test
"#;
OpaEngine::from_strings(TEST_POLICY, data).expect("failed to load wildcard test policy")
}

fn wildcard_input(host: &str) -> NetworkInput {
NetworkInput {
host: host.into(),
port: 443,
binary_path: PathBuf::from("/usr/bin/test"),
binary_sha256: "unused".into(),
ancestors: vec![],
cmdline_paths: vec![],
}
}
}
211 changes: 211 additions & 0 deletions crates/openshell-supervisor-network/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7110,6 +7110,195 @@ network_policies:
assert!(result.is_err());
}

// -- parse_target: CONNECT target parser regression tests --

#[test]
fn test_parse_target_valid_baseline() {
let (host, port) = parse_target("example.com:443").unwrap();
assert_eq!(host, "example.com");
assert_eq!(port, 443);
}

#[test]
fn test_parse_target_preserves_case() {
let (host, port) = parse_target("EXAMPLE.COM:443").unwrap();
assert_eq!(host, "EXAMPLE.COM", "parse_target should preserve case");
assert_eq!(port, 443);
}

#[test]
fn test_parse_target_accepts_empty_host() {
let (host, port) = parse_target(":443").unwrap();
assert!(host.is_empty(), "empty host accepted without validation");
assert_eq!(port, 443);
}

#[test]
fn test_parse_target_nul_byte_passes_through() {
let (host, _) = parse_target("evil.com\0.safe.com:443").unwrap();
assert_eq!(
host, "evil.com\0.safe.com",
"NUL byte not stripped or rejected"
);
}

#[test]
fn test_parse_target_control_char_passes_through() {
let (host, _) = parse_target("evil\x01.com:443").unwrap();
assert!(
host.contains('\x01'),
"control characters pass through without validation"
);
}

#[test]
fn test_parse_target_percent_encoded_dot_is_literal() {
let (host, _) = parse_target("evil%2ecom:443").unwrap();
assert_eq!(
host, "evil%2ecom",
"percent-encoded dot not decoded — literal %2e in host"
);
}

#[test]
fn test_parse_target_percent_encoded_nul_is_literal() {
let (host, _) = parse_target("evil%00.safe.com:443").unwrap();
assert_eq!(
host, "evil%00.safe.com",
"percent-encoded NUL not decoded — literal %00 in host"
);
}

#[test]
fn test_parse_target_rejects_missing_port_separator() {
assert!(
parse_target("hostonly").is_err(),
"missing colon should be rejected"
);
}

#[test]
fn test_parse_target_rejects_non_numeric_port() {
assert!(
parse_target("host:notaport").is_err(),
"non-numeric port should be rejected"
);
}

#[test]
fn test_parse_target_rejects_port_overflow() {
assert!(
parse_target("host:65536").is_err(),
"port > 65535 should be rejected by u16 parse"
);
}

#[test]
fn test_parse_target_accepts_port_zero() {
let (_, port) = parse_target("host:0").unwrap();
assert_eq!(port, 0);
}

#[test]
fn test_parse_target_accepts_port_max() {
let (_, port) = parse_target("host:65535").unwrap();
assert_eq!(port, 65535);
}

#[test]
fn test_parse_target_bracket_chars_pass_through() {
let (host, _) = parse_target("a]b[c:443").unwrap();
assert_eq!(host, "a]b[c", "brackets pass through without validation");
}

#[test]
fn test_parse_target_oversized_hostname_accepted() {
let long_host = "a".repeat(254);
let target = format!("{long_host}:443");
let (host, _) = parse_target(&target).unwrap();
assert_eq!(
host.len(),
254,
"hostname exceeding DNS 253-char limit not rejected"
);
}

#[test]
fn test_parse_target_backslash_passes_through() {
let (host, _) = parse_target("evil.com\\..safe.com:443").unwrap();
assert!(
host.contains('\\'),
"backslash passes through without validation"
);
}

#[test]
fn test_parse_target_slash_passes_through() {
let (host, _) = parse_target("evil.com/../safe.com:443").unwrap();
assert!(
host.contains('/'),
"forward slash passes through without validation"
);
}

#[test]
fn test_parse_target_extra_colon_fails_port_parse() {
assert!(
parse_target("host:80:extra").is_err(),
"trailing content after port should fail u16 parse"
);
}

#[test]
fn test_parse_target_ipv6_bracket_notation_fails() {
assert!(
parse_target("[::1]:443").is_err(),
"split_once splits at first colon inside brackets — port parse fails"
);
}

// -- parse_proxy_uri: hostname parser regression tests --

#[test]
fn test_parse_proxy_uri_nul_byte_in_host() {
let (_, host, port, _) = parse_proxy_uri("http://evil.com\0.safe.com:80/path").unwrap();
assert_eq!(
host, "evil.com\0.safe.com",
"NUL byte not stripped or rejected in forward proxy URI"
);
assert_eq!(port, 80);
}

#[test]
fn test_parse_proxy_uri_control_char_in_host() {
let (_, host, _, _) = parse_proxy_uri("http://evil\x01.com:80/").unwrap();
assert!(
host.contains('\x01'),
"control characters pass through without validation"
);
}

#[test]
fn test_parse_proxy_uri_percent_encoded_dot_in_host() {
let (_, host, _, _) = parse_proxy_uri("http://evil%2ecom:80/").unwrap();
assert_eq!(
host, "evil%2ecom",
"percent-encoded dot not decoded — literal %2e in host"
);
}

#[test]
fn test_parse_proxy_uri_oversized_hostname() {
let long_host = "a".repeat(254);
let uri = format!("http://{long_host}:80/");
let (_, host, _, _) = parse_proxy_uri(&uri).unwrap();
assert_eq!(
host.len(),
254,
"hostname exceeding DNS 253-char limit not rejected"
);
}

// --- rewrite_forward_request tests ---

#[tokio::test]
Expand Down Expand Up @@ -7612,6 +7801,28 @@ network_policies:
);
}

// -- SSRF: malformed hostname resolution regression tests --

#[tokio::test]
async fn test_resolve_reject_internal_fails_closed_on_nul_hostname() {
let result = resolve_and_reject_internal("evil.com\0.safe.com", 443, 0).await;
assert!(
result.is_err(),
"NUL-containing hostname should fail DNS resolution (fail closed)"
);
}

#[tokio::test]
async fn test_resolve_allowed_ips_fails_closed_on_nul_hostname() {
let nets = parse_allowed_ips(&["0.0.0.0/0".to_string()])
.unwrap_or_else(|_| vec!["0.0.0.0/0".parse::<ipnet::IpNet>().unwrap()]);
let result = resolve_and_check_allowed_ips("evil.com\0.safe.com", 443, &nets, 0).await;
assert!(
result.is_err(),
"NUL-containing hostname should fail DNS resolution (fail closed)"
);
}

// -- implicit_allowed_ips_for_ip_host --

#[test]
Expand Down
Loading