Skip to content

Commit 420a855

Browse files
authored
test(supervisor-network): add proxy hostname parser regression tests (#2197)
Add regression coverage for parser differentials in the egress proxy's CONNECT hostname handling and OPA wildcard policy matching. Signed-off-by: Shane Utt <shaneutt@linux.com>
1 parent 4970108 commit 420a855

2 files changed

Lines changed: 274 additions & 0 deletions

File tree

crates/openshell-supervisor-network/src/opa.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1575,6 +1575,40 @@ mod tests {
15751575
assert!(!decision.allowed);
15761576
}
15771577

1578+
// -- wildcard host: malformed hostname regression tests --
1579+
1580+
#[test]
1581+
fn wildcard_host_nul_byte_causes_opa_error() {
1582+
let engine = wildcard_host_engine();
1583+
let result = engine.evaluate_network(&wildcard_input("sub\0.example.com"));
1584+
assert!(
1585+
result.is_err(),
1586+
"NUL byte is an internal glob placeholder — OPA rejects it (fail closed)"
1587+
);
1588+
}
1589+
1590+
#[test]
1591+
fn wildcard_host_nul_byte_extra_label_causes_opa_error() {
1592+
let engine = wildcard_host_engine();
1593+
let result = engine.evaluate_network(&wildcard_input("evil.com\0.example.com"));
1594+
assert!(
1595+
result.is_err(),
1596+
"NUL byte in hostname causes OPA evaluation failure (fail closed)"
1597+
);
1598+
}
1599+
1600+
#[test]
1601+
fn wildcard_host_percent_encoded_dot_no_match() {
1602+
let engine = wildcard_host_engine();
1603+
let decision = engine
1604+
.evaluate_network(&wildcard_input("evil%2eexample.com"))
1605+
.unwrap();
1606+
assert!(
1607+
!decision.allowed,
1608+
"percent-encoded dot should not be decoded by OPA glob"
1609+
);
1610+
}
1611+
15781612
#[test]
15791613
fn query_sandbox_config_extracts_filesystem() {
15801614
let engine = test_engine();
@@ -6444,4 +6478,33 @@ network_policies:
64446478
let input = l7_input("h.test", 80, "HEAD", "/protected");
64456479
assert!(!eval_l7(&engine, &input));
64466480
}
6481+
6482+
// ---------------------------------------------------------------------------
6483+
// Test Utilities
6484+
// ---------------------------------------------------------------------------
6485+
6486+
fn wildcard_host_engine() -> OpaEngine {
6487+
let data = r#"
6488+
network_policies:
6489+
wildcard_test:
6490+
name: wildcard_test
6491+
endpoints:
6492+
- host: "*.example.com"
6493+
port: 443
6494+
binaries:
6495+
- path: /usr/bin/test
6496+
"#;
6497+
OpaEngine::from_strings(TEST_POLICY, data).expect("failed to load wildcard test policy")
6498+
}
6499+
6500+
fn wildcard_input(host: &str) -> NetworkInput {
6501+
NetworkInput {
6502+
host: host.into(),
6503+
port: 443,
6504+
binary_path: PathBuf::from("/usr/bin/test"),
6505+
binary_sha256: "unused".into(),
6506+
ancestors: vec![],
6507+
cmdline_paths: vec![],
6508+
}
6509+
}
64476510
}

crates/openshell-supervisor-network/src/proxy.rs

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7110,6 +7110,195 @@ network_policies:
71107110
assert!(result.is_err());
71117111
}
71127112

7113+
// -- parse_target: CONNECT target parser regression tests --
7114+
7115+
#[test]
7116+
fn test_parse_target_valid_baseline() {
7117+
let (host, port) = parse_target("example.com:443").unwrap();
7118+
assert_eq!(host, "example.com");
7119+
assert_eq!(port, 443);
7120+
}
7121+
7122+
#[test]
7123+
fn test_parse_target_preserves_case() {
7124+
let (host, port) = parse_target("EXAMPLE.COM:443").unwrap();
7125+
assert_eq!(host, "EXAMPLE.COM", "parse_target should preserve case");
7126+
assert_eq!(port, 443);
7127+
}
7128+
7129+
#[test]
7130+
fn test_parse_target_accepts_empty_host() {
7131+
let (host, port) = parse_target(":443").unwrap();
7132+
assert!(host.is_empty(), "empty host accepted without validation");
7133+
assert_eq!(port, 443);
7134+
}
7135+
7136+
#[test]
7137+
fn test_parse_target_nul_byte_passes_through() {
7138+
let (host, _) = parse_target("evil.com\0.safe.com:443").unwrap();
7139+
assert_eq!(
7140+
host, "evil.com\0.safe.com",
7141+
"NUL byte not stripped or rejected"
7142+
);
7143+
}
7144+
7145+
#[test]
7146+
fn test_parse_target_control_char_passes_through() {
7147+
let (host, _) = parse_target("evil\x01.com:443").unwrap();
7148+
assert!(
7149+
host.contains('\x01'),
7150+
"control characters pass through without validation"
7151+
);
7152+
}
7153+
7154+
#[test]
7155+
fn test_parse_target_percent_encoded_dot_is_literal() {
7156+
let (host, _) = parse_target("evil%2ecom:443").unwrap();
7157+
assert_eq!(
7158+
host, "evil%2ecom",
7159+
"percent-encoded dot not decoded — literal %2e in host"
7160+
);
7161+
}
7162+
7163+
#[test]
7164+
fn test_parse_target_percent_encoded_nul_is_literal() {
7165+
let (host, _) = parse_target("evil%00.safe.com:443").unwrap();
7166+
assert_eq!(
7167+
host, "evil%00.safe.com",
7168+
"percent-encoded NUL not decoded — literal %00 in host"
7169+
);
7170+
}
7171+
7172+
#[test]
7173+
fn test_parse_target_rejects_missing_port_separator() {
7174+
assert!(
7175+
parse_target("hostonly").is_err(),
7176+
"missing colon should be rejected"
7177+
);
7178+
}
7179+
7180+
#[test]
7181+
fn test_parse_target_rejects_non_numeric_port() {
7182+
assert!(
7183+
parse_target("host:notaport").is_err(),
7184+
"non-numeric port should be rejected"
7185+
);
7186+
}
7187+
7188+
#[test]
7189+
fn test_parse_target_rejects_port_overflow() {
7190+
assert!(
7191+
parse_target("host:65536").is_err(),
7192+
"port > 65535 should be rejected by u16 parse"
7193+
);
7194+
}
7195+
7196+
#[test]
7197+
fn test_parse_target_accepts_port_zero() {
7198+
let (_, port) = parse_target("host:0").unwrap();
7199+
assert_eq!(port, 0);
7200+
}
7201+
7202+
#[test]
7203+
fn test_parse_target_accepts_port_max() {
7204+
let (_, port) = parse_target("host:65535").unwrap();
7205+
assert_eq!(port, 65535);
7206+
}
7207+
7208+
#[test]
7209+
fn test_parse_target_bracket_chars_pass_through() {
7210+
let (host, _) = parse_target("a]b[c:443").unwrap();
7211+
assert_eq!(host, "a]b[c", "brackets pass through without validation");
7212+
}
7213+
7214+
#[test]
7215+
fn test_parse_target_oversized_hostname_accepted() {
7216+
let long_host = "a".repeat(254);
7217+
let target = format!("{long_host}:443");
7218+
let (host, _) = parse_target(&target).unwrap();
7219+
assert_eq!(
7220+
host.len(),
7221+
254,
7222+
"hostname exceeding DNS 253-char limit not rejected"
7223+
);
7224+
}
7225+
7226+
#[test]
7227+
fn test_parse_target_backslash_passes_through() {
7228+
let (host, _) = parse_target("evil.com\\..safe.com:443").unwrap();
7229+
assert!(
7230+
host.contains('\\'),
7231+
"backslash passes through without validation"
7232+
);
7233+
}
7234+
7235+
#[test]
7236+
fn test_parse_target_slash_passes_through() {
7237+
let (host, _) = parse_target("evil.com/../safe.com:443").unwrap();
7238+
assert!(
7239+
host.contains('/'),
7240+
"forward slash passes through without validation"
7241+
);
7242+
}
7243+
7244+
#[test]
7245+
fn test_parse_target_extra_colon_fails_port_parse() {
7246+
assert!(
7247+
parse_target("host:80:extra").is_err(),
7248+
"trailing content after port should fail u16 parse"
7249+
);
7250+
}
7251+
7252+
#[test]
7253+
fn test_parse_target_ipv6_bracket_notation_fails() {
7254+
assert!(
7255+
parse_target("[::1]:443").is_err(),
7256+
"split_once splits at first colon inside brackets — port parse fails"
7257+
);
7258+
}
7259+
7260+
// -- parse_proxy_uri: hostname parser regression tests --
7261+
7262+
#[test]
7263+
fn test_parse_proxy_uri_nul_byte_in_host() {
7264+
let (_, host, port, _) = parse_proxy_uri("http://evil.com\0.safe.com:80/path").unwrap();
7265+
assert_eq!(
7266+
host, "evil.com\0.safe.com",
7267+
"NUL byte not stripped or rejected in forward proxy URI"
7268+
);
7269+
assert_eq!(port, 80);
7270+
}
7271+
7272+
#[test]
7273+
fn test_parse_proxy_uri_control_char_in_host() {
7274+
let (_, host, _, _) = parse_proxy_uri("http://evil\x01.com:80/").unwrap();
7275+
assert!(
7276+
host.contains('\x01'),
7277+
"control characters pass through without validation"
7278+
);
7279+
}
7280+
7281+
#[test]
7282+
fn test_parse_proxy_uri_percent_encoded_dot_in_host() {
7283+
let (_, host, _, _) = parse_proxy_uri("http://evil%2ecom:80/").unwrap();
7284+
assert_eq!(
7285+
host, "evil%2ecom",
7286+
"percent-encoded dot not decoded — literal %2e in host"
7287+
);
7288+
}
7289+
7290+
#[test]
7291+
fn test_parse_proxy_uri_oversized_hostname() {
7292+
let long_host = "a".repeat(254);
7293+
let uri = format!("http://{long_host}:80/");
7294+
let (_, host, _, _) = parse_proxy_uri(&uri).unwrap();
7295+
assert_eq!(
7296+
host.len(),
7297+
254,
7298+
"hostname exceeding DNS 253-char limit not rejected"
7299+
);
7300+
}
7301+
71137302
// --- rewrite_forward_request tests ---
71147303

71157304
#[tokio::test]
@@ -7612,6 +7801,28 @@ network_policies:
76127801
);
76137802
}
76147803

7804+
// -- SSRF: malformed hostname resolution regression tests --
7805+
7806+
#[tokio::test]
7807+
async fn test_resolve_reject_internal_fails_closed_on_nul_hostname() {
7808+
let result = resolve_and_reject_internal("evil.com\0.safe.com", 443, 0).await;
7809+
assert!(
7810+
result.is_err(),
7811+
"NUL-containing hostname should fail DNS resolution (fail closed)"
7812+
);
7813+
}
7814+
7815+
#[tokio::test]
7816+
async fn test_resolve_allowed_ips_fails_closed_on_nul_hostname() {
7817+
let nets = parse_allowed_ips(&["0.0.0.0/0".to_string()])
7818+
.unwrap_or_else(|_| vec!["0.0.0.0/0".parse::<ipnet::IpNet>().unwrap()]);
7819+
let result = resolve_and_check_allowed_ips("evil.com\0.safe.com", 443, &nets, 0).await;
7820+
assert!(
7821+
result.is_err(),
7822+
"NUL-containing hostname should fail DNS resolution (fail closed)"
7823+
);
7824+
}
7825+
76157826
// -- implicit_allowed_ips_for_ip_host --
76167827

76177828
#[test]

0 commit comments

Comments
 (0)