From 398979a649cff72034102dd0a95e7a0bb94fba6c Mon Sep 17 00:00:00 2001 From: mesutoezdil Date: Thu, 14 May 2026 14:48:08 +0200 Subject: [PATCH 1/2] fix(sandbox): allow HEAD where GET is permitted in L7 policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9110 §9.3.2 defines HEAD as identical to GET except no response body. method_matches in sandbox-policy.rego rejected HEAD even when GET was explicitly allowed, breaking package managers that probe with HEAD before fetching. Signed-off-by: mesutoezdil --- .../data/sandbox-policy.rego | 6 +++++ crates/openshell-sandbox/src/opa.rs | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+) 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..ee97dc15a 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -4805,4 +4805,29 @@ 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: /bin/sh}\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)); + } } From 2bffa0492b56feafae434a00a7e829d3c848f2b0 Mon Sep 17 00:00:00 2001 From: mesutoezdil Date: Fri, 15 May 2026 18:05:58 +0200 Subject: [PATCH 2/2] test(sandbox): fix binary mismatch in HEAD/POST test and add deny-rule regression l7_head_denied_when_only_post_allowed used /bin/sh in the fixture but l7_input() sends /usr/bin/curl, so requests were denied by binary policy before the method rule could be evaluated. A regression allowing HEAD to match POST would have passed undetected. Fixes the fixture binary and adds l7_head_blocked_by_deny_rule_targeting_get to cover the deny_rules path of method_matches(), per review feedback. Signed-off-by: mesutoezdil --- crates/openshell-sandbox/src/opa.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index ee97dc15a..e9058372d 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -4817,7 +4817,7 @@ network_policies: 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: /bin/sh}\n", + "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", "/"); @@ -4830,4 +4830,16 @@ network_policies: 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)); + } }