Skip to content

Commit e016129

Browse files
committed
fix(policy): deny query matching fails closed, mirror allow-side validation
Addresses PR review findings P1 and P3: P1: Deny-side query matching now uses fail-closed semantics. If ANY value for a query key matches the deny matcher, the deny fires. The previous implementation reused allow-side "all values must match" logic which allowed ?force=true&force=false to bypass a deny on force=true. P3: Deny-side query validation now mirrors the full allow-side checks: empty any lists, non-string matcher values, glob+any mutual exclusion, glob type checks, and glob syntax warnings are all validated.
1 parent 921db61 commit e016129

File tree

3 files changed

+340
-34
lines changed

3 files changed

+340
-34
lines changed

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

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -239,17 +239,49 @@ request_denied_for_endpoint(request, endpoint) if {
239239
command_matches(request.command, deny_rule.command)
240240
}
241241

242-
# Deny query matching: if no query rules on deny, match any query params.
243-
# If query rules present, all configured keys must match.
242+
# Deny query matching: fail-closed semantics.
243+
# If no query rules on the deny rule, match unconditionally (any query params).
244+
# If query rules present, trigger the deny if ANY value for a configured key
245+
# matches the matcher. This is the inverse of allow-side semantics where ALL
246+
# values must match. For deny logic, a single matching value is enough to block.
244247
deny_query_params_match(request, deny_rule) if {
245248
deny_query_rules := object.get(deny_rule, "query", {})
246-
not deny_query_mismatch(request, deny_query_rules)
249+
count(deny_query_rules) == 0
247250
}
248251

249-
deny_query_mismatch(request, query_rules) if {
252+
deny_query_params_match(request, deny_rule) if {
253+
deny_query_rules := object.get(deny_rule, "query", {})
254+
count(deny_query_rules) > 0
255+
not deny_query_key_missing(request, deny_query_rules)
256+
not deny_query_value_mismatch_all(request, deny_query_rules)
257+
}
258+
259+
# A configured deny query key is missing from the request entirely.
260+
# Missing key means the deny rule doesn't apply (fail-open on absence).
261+
deny_query_key_missing(request, query_rules) if {
262+
some key
263+
query_rules[key]
264+
request_query := object.get(request, "query_params", {})
265+
values := object.get(request_query, key, null)
266+
values == null
267+
}
268+
269+
# ALL values for a configured key fail to match the matcher.
270+
# If even one value matches, deny fires. This rule checks the opposite:
271+
# true when NO value matches (i.e., every value is a mismatch).
272+
deny_query_value_mismatch_all(request, query_rules) if {
250273
some key
251274
matcher := query_rules[key]
252-
not query_key_matches(request, key, matcher)
275+
request_query := object.get(request, "query_params", {})
276+
values := object.get(request_query, key, [])
277+
count(values) > 0
278+
not deny_any_value_matches(values, matcher)
279+
}
280+
281+
# True if at least one value in the list matches the matcher.
282+
deny_any_value_matches(values, matcher) if {
283+
some i
284+
query_value_matches(values[i], matcher)
253285
}
254286

255287
# --- L7 deny reason ---

crates/openshell-sandbox/src/l7/mod.rs

Lines changed: 278 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -377,42 +377,102 @@ pub fn validate_l7_policies(data_json: &serde_json::Value) -> (Vec<String>, Vec<
377377
}
378378
}
379379

380-
// Validate query matchers (same rules as allow query matchers)
380+
// Validate query matchers — mirrors allow-side validation exactly
381381
if let Some(query) = deny_rule.get("query").filter(|v| !v.is_null()) {
382-
if let Some(query_obj) = query.as_object() {
383-
for (param, matcher) in query_obj {
384-
if let Some(glob_str) = matcher.as_str() {
385-
if let Some(warning) = check_glob_syntax(glob_str) {
386-
warnings.push(format!(
387-
"{deny_loc}.query.{param}: {warning}"
388-
));
389-
}
390-
continue;
382+
let Some(query_obj) = query.as_object() else {
383+
errors.push(format!(
384+
"{deny_loc}.query: expected map of query matchers"
385+
));
386+
continue;
387+
};
388+
389+
for (param, matcher) in query_obj {
390+
if let Some(glob_str) = matcher.as_str() {
391+
if let Some(warning) = check_glob_syntax(glob_str) {
392+
warnings
393+
.push(format!("{deny_loc}.query.{param}: {warning}"));
391394
}
392-
if let Some(matcher_obj) = matcher.as_object() {
393-
let has_any = matcher_obj.get("any").is_some();
394-
let has_glob = matcher_obj.get("glob").is_some();
395-
let has_unknown =
396-
matcher_obj.keys().any(|k| k != "any" && k != "glob");
397-
if has_unknown {
398-
errors.push(format!(
399-
"{deny_loc}.query.{param}: unknown matcher keys; only `glob` or `any` are supported"
400-
));
401-
} else if has_glob && has_any {
402-
errors.push(format!(
403-
"{deny_loc}.query.{param}: matcher cannot specify both `glob` and `any`"
404-
));
405-
} else if !has_glob && !has_any {
395+
continue;
396+
}
397+
398+
let Some(matcher_obj) = matcher.as_object() else {
399+
errors.push(format!(
400+
"{deny_loc}.query.{param}: expected string glob or object with `any`"
401+
));
402+
continue;
403+
};
404+
405+
let has_any = matcher_obj.get("any").is_some();
406+
let has_glob = matcher_obj.get("glob").is_some();
407+
let has_unknown =
408+
matcher_obj.keys().any(|k| k != "any" && k != "glob");
409+
if has_unknown {
410+
errors.push(format!(
411+
"{deny_loc}.query.{param}: unknown matcher keys; only `glob` or `any` are supported"
412+
));
413+
continue;
414+
}
415+
416+
if has_glob && has_any {
417+
errors.push(format!(
418+
"{deny_loc}.query.{param}: matcher cannot specify both `glob` and `any`"
419+
));
420+
continue;
421+
}
422+
423+
if !has_glob && !has_any {
424+
errors.push(format!(
425+
"{deny_loc}.query.{param}: object matcher requires `glob` string or non-empty `any` list"
426+
));
427+
continue;
428+
}
429+
430+
if has_glob {
431+
match matcher_obj.get("glob").and_then(|v| v.as_str()) {
432+
None => {
406433
errors.push(format!(
407-
"{deny_loc}.query.{param}: object matcher requires `glob` string or non-empty `any` list"
434+
"{deny_loc}.query.{param}.glob: expected glob string"
408435
));
409436
}
437+
Some(g) => {
438+
if let Some(warning) = check_glob_syntax(g) {
439+
warnings.push(format!(
440+
"{deny_loc}.query.{param}.glob: {warning}"
441+
));
442+
}
443+
}
444+
}
445+
continue;
446+
}
447+
448+
let any = matcher_obj.get("any").and_then(|v| v.as_array());
449+
let Some(any) = any else {
450+
errors.push(format!(
451+
"{deny_loc}.query.{param}.any: expected array of glob strings"
452+
));
453+
continue;
454+
};
455+
456+
if any.is_empty() {
457+
errors.push(format!(
458+
"{deny_loc}.query.{param}.any: list must not be empty"
459+
));
460+
continue;
461+
}
462+
463+
if any.iter().any(|v| v.as_str().is_none()) {
464+
errors.push(format!(
465+
"{deny_loc}.query.{param}.any: all values must be strings"
466+
));
467+
}
468+
469+
for item in any.iter().filter_map(|v| v.as_str()) {
470+
if let Some(warning) = check_glob_syntax(item) {
471+
warnings.push(format!(
472+
"{deny_loc}.query.{param}.any: {warning}"
473+
));
410474
}
411475
}
412-
} else {
413-
errors.push(format!(
414-
"{deny_loc}.query: expected map of query matchers"
415-
));
416476
}
417477
}
418478

@@ -1269,4 +1329,193 @@ mod tests {
12691329
"valid query matcher shapes should not error: {errors:?}"
12701330
);
12711331
}
1332+
1333+
// --- Deny rules validation tests ---
1334+
1335+
#[test]
1336+
fn validate_deny_rules_require_protocol() {
1337+
let data = serde_json::json!({
1338+
"network_policies": {
1339+
"test": {
1340+
"endpoints": [{
1341+
"host": "api.example.com",
1342+
"port": 443,
1343+
"deny_rules": [{ "method": "POST", "path": "/admin" }]
1344+
}],
1345+
"binaries": []
1346+
}
1347+
}
1348+
});
1349+
let (errors, _) = validate_l7_policies(&data);
1350+
assert!(
1351+
errors
1352+
.iter()
1353+
.any(|e| e.contains("deny_rules require protocol")),
1354+
"should require protocol for deny_rules: {errors:?}"
1355+
);
1356+
}
1357+
1358+
#[test]
1359+
fn validate_deny_rules_require_allow_base() {
1360+
let data = serde_json::json!({
1361+
"network_policies": {
1362+
"test": {
1363+
"endpoints": [{
1364+
"host": "api.example.com",
1365+
"port": 443,
1366+
"protocol": "rest",
1367+
"deny_rules": [{ "method": "POST", "path": "/admin" }]
1368+
}],
1369+
"binaries": []
1370+
}
1371+
}
1372+
});
1373+
let (errors, _) = validate_l7_policies(&data);
1374+
assert!(
1375+
errors
1376+
.iter()
1377+
.any(|e| e.contains("deny_rules require rules or access")),
1378+
"should require rules or access for deny_rules: {errors:?}"
1379+
);
1380+
}
1381+
1382+
#[test]
1383+
fn validate_deny_rules_empty_list_rejected() {
1384+
let data = serde_json::json!({
1385+
"network_policies": {
1386+
"test": {
1387+
"endpoints": [{
1388+
"host": "api.example.com",
1389+
"port": 443,
1390+
"protocol": "rest",
1391+
"access": "full",
1392+
"deny_rules": []
1393+
}],
1394+
"binaries": []
1395+
}
1396+
}
1397+
});
1398+
let (errors, _) = validate_l7_policies(&data);
1399+
assert!(
1400+
errors
1401+
.iter()
1402+
.any(|e| e.contains("deny_rules list cannot be empty")),
1403+
"should reject empty deny_rules: {errors:?}"
1404+
);
1405+
}
1406+
1407+
#[test]
1408+
fn validate_deny_rules_valid_config_accepted() {
1409+
let data = serde_json::json!({
1410+
"network_policies": {
1411+
"test": {
1412+
"endpoints": [{
1413+
"host": "api.example.com",
1414+
"port": 443,
1415+
"protocol": "rest",
1416+
"access": "read-write",
1417+
"deny_rules": [
1418+
{ "method": "POST", "path": "/repos/*/pulls/*/reviews" },
1419+
{ "method": "PUT", "path": "/repos/*/branches/*/protection" }
1420+
]
1421+
}],
1422+
"binaries": []
1423+
}
1424+
}
1425+
});
1426+
let (errors, _) = validate_l7_policies(&data);
1427+
assert!(
1428+
errors.is_empty(),
1429+
"valid deny_rules should not error: {errors:?}"
1430+
);
1431+
}
1432+
1433+
#[test]
1434+
fn validate_deny_rules_query_empty_any_rejected() {
1435+
let data = serde_json::json!({
1436+
"network_policies": {
1437+
"test": {
1438+
"endpoints": [{
1439+
"host": "api.example.com",
1440+
"port": 443,
1441+
"protocol": "rest",
1442+
"access": "full",
1443+
"deny_rules": [{
1444+
"method": "POST",
1445+
"path": "/admin",
1446+
"query": { "type": { "any": [] } }
1447+
}]
1448+
}],
1449+
"binaries": []
1450+
}
1451+
}
1452+
});
1453+
let (errors, _) = validate_l7_policies(&data);
1454+
assert!(
1455+
errors
1456+
.iter()
1457+
.any(|e| e.contains("any: list must not be empty")),
1458+
"should reject empty any list in deny query: {errors:?}"
1459+
);
1460+
}
1461+
1462+
#[test]
1463+
fn validate_deny_rules_query_non_string_rejected() {
1464+
let data = serde_json::json!({
1465+
"network_policies": {
1466+
"test": {
1467+
"endpoints": [{
1468+
"host": "api.example.com",
1469+
"port": 443,
1470+
"protocol": "rest",
1471+
"access": "full",
1472+
"deny_rules": [{
1473+
"method": "POST",
1474+
"path": "/admin",
1475+
"query": { "force": 123 }
1476+
}]
1477+
}],
1478+
"binaries": []
1479+
}
1480+
}
1481+
});
1482+
let (errors, _) = validate_l7_policies(&data);
1483+
assert!(
1484+
errors
1485+
.iter()
1486+
.any(|e| e.contains("expected string glob or object")),
1487+
"should reject non-string/non-object matcher in deny query: {errors:?}"
1488+
);
1489+
}
1490+
1491+
#[test]
1492+
fn validate_deny_rules_query_valid_matchers_accepted() {
1493+
let data = serde_json::json!({
1494+
"network_policies": {
1495+
"test": {
1496+
"endpoints": [{
1497+
"host": "api.example.com",
1498+
"port": 443,
1499+
"protocol": "rest",
1500+
"access": "full",
1501+
"deny_rules": [{
1502+
"method": "POST",
1503+
"path": "/admin/**",
1504+
"query": {
1505+
"force": "true",
1506+
"type": { "any": ["admin-*", "root-*"] },
1507+
"scope": { "glob": "org-*" }
1508+
}
1509+
}]
1510+
}],
1511+
"binaries": []
1512+
}
1513+
}
1514+
});
1515+
let (errors, _) = validate_l7_policies(&data);
1516+
assert!(
1517+
errors.is_empty(),
1518+
"valid deny query matchers should not error: {errors:?}"
1519+
);
1520+
}
12721521
}

0 commit comments

Comments
 (0)