diff --git a/crates/openshell-sandbox/data/sandbox-policy.rego b/crates/openshell-sandbox/data/sandbox-policy.rego index a8e4affce..0fa1e6be7 100644 --- a/crates/openshell-sandbox/data/sandbox-policy.rego +++ b/crates/openshell-sandbox/data/sandbox-policy.rego @@ -526,6 +526,7 @@ graphql_field_matches_any(field, patterns) if { } # Wildcard "*" matches any method; otherwise case-insensitive exact match. +# RFC 9110 ยง9.3.2: HEAD is semantically identical to GET except no response body. method_matches(_, "*") if true method_matches(actual, expected) if { @@ -533,6 +534,11 @@ method_matches(actual, expected) if { upper(actual) == upper(expected) } +method_matches(actual, expected) if { + upper(actual) == "HEAD" + upper(expected) == "GET" +} + # Path matching: "**" matches everything; otherwise glob.match with "/" delimiter. # # INVARIANT: `input.request.path` is canonicalized by the sandbox before diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index a9ab94a2b..e9058372d 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -4805,4 +4805,41 @@ network_policies: decision.reason ); } + + #[test] + fn l7_head_allowed_where_get_is_allowed() { + let engine = l7_engine(); + let input = l7_input("api.example.com", 8080, "HEAD", "/repos/myorg/foo"); + assert!(eval_l7(&engine, &input)); + } + + #[test] + fn l7_head_denied_when_only_post_allowed() { + let engine = OpaEngine::from_strings( + TEST_POLICY, + "network_policies:\n p:\n name: p\n endpoints:\n - host: h.test\n port: 80\n protocol: rest\n enforcement: enforce\n rules:\n - allow: {method: POST, path: \"/\"}\n binaries:\n - {path: /usr/bin/curl}\n", + ) + .unwrap(); + let input = l7_input("h.test", 80, "HEAD", "/"); + assert!(!eval_l7(&engine, &input)); + } + + #[test] + fn l7_options_not_implicitly_allowed_by_get() { + let engine = l7_engine(); + let input = l7_input("api.example.com", 8080, "OPTIONS", "/repos/myorg/foo"); + assert!(!eval_l7(&engine, &input)); + } + + #[test] + fn l7_head_blocked_by_deny_rule_targeting_get() { + // deny_rules use method_matches() too; a deny on GET must also block HEAD. + let engine = OpaEngine::from_strings( + TEST_POLICY, + "network_policies:\n p:\n name: p\n endpoints:\n - host: h.test\n port: 80\n protocol: rest\n enforcement: enforce\n access: full\n deny_rules:\n - method: GET\n path: \"/protected\"\n binaries:\n - {path: /usr/bin/curl}\n", + ) + .unwrap(); + let input = l7_input("h.test", 80, "HEAD", "/protected"); + assert!(!eval_l7(&engine, &input)); + } }