Skip to content

Commit 256f7fc

Browse files
authored
fix(sandbox,server): fix chunk merge duplicates and OPA variable collision with overlapping policies (#571)
* fix(sandbox,server): fix chunk merge duplicates and OPA variable collision with overlapping policies Two related bugs triggered when a draft rule approval creates a second policy entry for the same host:port: 1. merge_chunk_into_policy looked up existing rules by chunk.rule_name (auto-generated as allow_{host}_{port}), which never matched the user's original rule name. Now scans all network_policies entries for a host:port endpoint match before falling back to insertion, and merges allowed_ips into the existing endpoint. 2. The Rego allow_request rule and _matching_endpoint_configs comprehension used 'some ep; ep := policy.endpoints[_]' which caused regorus to error with 'duplicated definition of local variable ep' when multiple policies covered the same host:port. Refactored to isolate endpoint iteration inside helper functions (_policy_allows_l7, _policy_endpoint_configs) so variables are scoped per-policy evaluation. Refs: #567 * test(e2e): add overlapping policy tests and update FWD-2 for implicit allowed_ips - Update FWD-2 (test_forward_proxy_denied_without_allowed_ips -> test_forward_proxy_allows_private_ip_host_without_allowed_ips): literal IP host no longer requires explicit allowed_ips, expects 200. - Add OVL-1: overlapping L4 policies for same host:port must not crash OPA and should allow forward proxy connections. - Add OVL-2: overlapping L7 policies for same host:port must not crash OPA and should allow CONNECT tunnel establishment. Refs: #567 * style: apply cargo fmt formatting * test(e2e): update SSRF-3 and SSRF-6 for implicit allowed_ips behavior SSRF-6: Private IP with literal IP host now gets implicit allowed_ips from PR #570, so CONNECT returns 200 instead of 403. SSRF-3: Loopback is still blocked but via the always-blocked path (implicit allowed_ips is synthesized, then resolve_and_check_allowed_ips catches it). Log message says 'always-blocked' instead of 'internal address'. * fix(e2e): use negative assertion for SSRF-6 when nothing listens on target port When the SSRF check passes but nothing listens on the target port, recv() returns empty bytes. Use 'assert 403 not in' (matching SSRF-4 pattern) instead of 'assert 200 in'. * fix(e2e): update provider tests for redacted credential values PR #569 changed credential redaction from clearing the map to replacing values with 'REDACTED'. Update e2e assertions to expect credential keys with REDACTED values instead of an empty map.
1 parent 3f1917a commit 256f7fc

File tree

5 files changed

+516
-50
lines changed

5 files changed

+516
-50
lines changed

crates/openshell-sandbox/data/sandbox-policy.rego

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -171,16 +171,25 @@ network_action := "allow" if {
171171

172172
default allow_request = false
173173

174-
# L7 request allowed if: L4 policy matches AND the specific endpoint's rules allow the request.
174+
# Per-policy helper: true when this single policy has at least one endpoint
175+
# matching the L4 request whose L7 rules also permit the specific request.
176+
# Isolating the endpoint iteration inside a function avoids the regorus
177+
# "duplicated definition of local variable" error that occurs when the
178+
# outer `some name` iterates over multiple policies that share a host:port.
179+
_policy_allows_l7(policy) if {
180+
some ep
181+
ep := policy.endpoints[_]
182+
endpoint_matches_request(ep, input.network)
183+
request_allowed_for_endpoint(input.request, ep)
184+
}
185+
186+
# L7 request allowed if any matching L4 policy also allows the L7 request.
175187
allow_request if {
176188
some name
177189
policy := data.network_policies[name]
178190
endpoint_allowed(policy, input.network)
179191
binary_allowed(policy, input.exec)
180-
some ep
181-
ep := policy.endpoints[_]
182-
endpoint_matches_request(ep, input.network)
183-
request_allowed_for_endpoint(input.request, ep)
192+
_policy_allows_l7(policy)
184193
}
185194

186195
# --- L7 deny reason ---
@@ -239,19 +248,24 @@ command_matches(actual, expected) if {
239248
# Used by Rust to extract L7 config (protocol, tls, enforcement) and/or
240249
# allowed_ips for SSRF allowlist validation.
241250

242-
# Collect all matching endpoint configs into an array to avoid complete-rule
243-
# conflicts when multiple policies cover the same endpoint. Return the first.
244-
_matching_endpoint_configs := [ep |
245-
some name
246-
policy := data.network_policies[name]
247-
endpoint_allowed(policy, input.network)
248-
binary_allowed(policy, input.exec)
251+
# Per-policy helper: returns matching endpoint configs for a single policy.
252+
_policy_endpoint_configs(policy) := [ep |
249253
some ep
250254
ep := policy.endpoints[_]
251255
endpoint_matches_request(ep, input.network)
252256
endpoint_has_extended_config(ep)
253257
]
254258

259+
# Collect matching endpoint configs across all policies. Iterates over
260+
# _matching_policy_names (a set, safe from regorus variable collisions)
261+
# then collects per-policy configs via the helper function.
262+
_matching_endpoint_configs := [cfg |
263+
some pname
264+
_matching_policy_names[pname]
265+
cfgs := _policy_endpoint_configs(data.network_policies[pname])
266+
cfg := cfgs[_]
267+
]
268+
255269
matched_endpoint_config := _matching_endpoint_configs[0] if {
256270
count(_matching_endpoint_configs) > 0
257271
}

crates/openshell-sandbox/src/opa.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1568,6 +1568,92 @@ process:
15681568
assert_eq!(val, regorus::Value::from(true));
15691569
}
15701570

1571+
// ========================================================================
1572+
// Overlapping policies (duplicate host:port) — regression tests
1573+
// ========================================================================
1574+
1575+
/// Two network_policies entries covering the same host:port with L7 rules.
1576+
/// Before the fix, this caused regorus to fail with
1577+
/// "duplicated definition of local variable ep" in allow_request.
1578+
const OVERLAPPING_L7_TEST_DATA: &str = r#"
1579+
network_policies:
1580+
test_server:
1581+
name: test_server
1582+
endpoints:
1583+
- host: 192.168.1.100
1584+
port: 8567
1585+
protocol: rest
1586+
enforcement: enforce
1587+
rules:
1588+
- allow:
1589+
method: GET
1590+
path: "**"
1591+
binaries:
1592+
- { path: /usr/bin/curl }
1593+
allow_192_168_1_100_8567:
1594+
name: allow_192_168_1_100_8567
1595+
endpoints:
1596+
- host: 192.168.1.100
1597+
port: 8567
1598+
protocol: rest
1599+
enforcement: enforce
1600+
allowed_ips:
1601+
- 192.168.1.100
1602+
rules:
1603+
- allow:
1604+
method: GET
1605+
path: "**"
1606+
binaries:
1607+
- { path: /usr/bin/curl }
1608+
filesystem_policy:
1609+
include_workdir: true
1610+
read_only: []
1611+
read_write: []
1612+
landlock:
1613+
compatibility: best_effort
1614+
process:
1615+
run_as_user: sandbox
1616+
run_as_group: sandbox
1617+
"#;
1618+
1619+
#[test]
1620+
fn l7_overlapping_policies_allow_request_does_not_crash() {
1621+
let engine = OpaEngine::from_strings(TEST_POLICY, OVERLAPPING_L7_TEST_DATA)
1622+
.expect("engine should load overlapping data");
1623+
let input = l7_input("192.168.1.100", 8567, "GET", "/test");
1624+
// Should not panic or error — must evaluate to true.
1625+
assert!(eval_l7(&engine, &input));
1626+
}
1627+
1628+
#[test]
1629+
fn l7_overlapping_policies_deny_request_does_not_crash() {
1630+
let engine = OpaEngine::from_strings(TEST_POLICY, OVERLAPPING_L7_TEST_DATA)
1631+
.expect("engine should load overlapping data");
1632+
let input = l7_input("192.168.1.100", 8567, "DELETE", "/test");
1633+
// DELETE is not in the rules, so should deny — but must not crash.
1634+
assert!(!eval_l7(&engine, &input));
1635+
}
1636+
1637+
#[test]
1638+
fn overlapping_policies_endpoint_config_returns_result() {
1639+
let engine = OpaEngine::from_strings(TEST_POLICY, OVERLAPPING_L7_TEST_DATA)
1640+
.expect("engine should load overlapping data");
1641+
let input = NetworkInput {
1642+
host: "192.168.1.100".into(),
1643+
port: 8567,
1644+
binary_path: PathBuf::from("/usr/bin/curl"),
1645+
binary_sha256: String::new(),
1646+
ancestors: vec![],
1647+
cmdline_paths: vec![],
1648+
};
1649+
// Should return config from one of the entries without error.
1650+
let config = engine.query_endpoint_config(&input).unwrap();
1651+
assert!(
1652+
config.is_some(),
1653+
"Expected endpoint config for overlapping policies"
1654+
);
1655+
}
1656+
15711657
// ========================================================================
15721658
// network_action tests
15731659
// ========================================================================

0 commit comments

Comments
 (0)