Skip to content

Commit deed4de

Browse files
committed
fix(policy): reject unsupported tls endpoint values
Signed-off-by: Yuedong Wu <dwcn22@outlook.com>
1 parent c502be9 commit deed4de

27 files changed

Lines changed: 291 additions & 126 deletions

File tree

architecture/security-policy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ stream, but it does not inspect TLS SNI, HTTP `Host`, or another protocol-level
123123
destination. Compatible shared infrastructure can therefore let a client
124124
select another tenant, virtual host, or service behind the approved front door.
125125

126+
An endpoint's `tls` field is either omitted, meaning auto-detect and terminate
127+
for inspection, or `skip`. Every other value fails shared L7 endpoint
128+
validation, so a policy cannot name a transport mode the proxy does not
129+
implement.
130+
126131
## Credentialed Endpoints
127132

128133
OpenShell keeps provider credentials on paths it can inspect or rewrite by

crates/openshell-cli/tests/provider_commands_integration.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2604,7 +2604,6 @@ endpoints:
26042604
- host: api.advanced.example
26052605
ports: [443, 8443]
26062606
protocol: rest
2607-
tls: terminate
26082607
enforcement: enforce
26092608
rules:
26102609
- allow:

crates/openshell-driver-mxc/tests/wxc_exec_real.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -732,7 +732,6 @@ async fn pc_https_egress_reads_injected_ca_bundle() {
732732
host: "example.com".to_string(),
733733
ports: vec![443],
734734
protocol: "rest".to_string(),
735-
tls: "terminate".to_string(),
736735
enforcement: "enforce".to_string(),
737736
access: "read-only".to_string(),
738737
..Default::default()

crates/openshell-policy/src/l7_validate.rs

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,23 @@ pub fn is_explicit_tcp_protocol(protocol: &str) -> bool {
4949
protocol.eq_ignore_ascii_case("tcp")
5050
}
5151

52+
/// `skip` is deliberately not offered as an alternative: it is a posture
53+
/// downgrade, not a fix.
54+
const TLS_MODE_REMEDIATION: &str = "remove the tls field to keep automatic TLS termination";
55+
56+
/// Reject `tls` values outside the supported set.
57+
///
58+
/// Omitted means auto-detect and terminate for inspection; `skip` opts out.
59+
pub fn validate_tls_mode(tls: &str) -> Option<String> {
60+
if tls.is_empty() || tls.eq_ignore_ascii_case("skip") {
61+
return None;
62+
}
63+
64+
Some(format!(
65+
"unsupported tls value '{tls}'; {TLS_MODE_REMEDIATION}"
66+
))
67+
}
68+
5269
/// Reject transport choices that an in-sandbox agent must not grant itself.
5370
///
5471
/// An omitted protocol remains allowed: it uses the established explicit
@@ -101,12 +118,33 @@ mod agent_transport_tests {
101118
#[test]
102119
fn agent_cannot_request_native_tcp_or_skip_tls_inspection() {
103120
assert!(agent_authored_transport_rejection("tcp", "").is_some());
104-
assert!(agent_authored_transport_rejection("TCP", "terminate").is_some());
121+
assert!(agent_authored_transport_rejection("TCP", "").is_some());
105122
assert!(agent_authored_transport_rejection("", "skip").is_some());
106123
assert!(agent_authored_transport_rejection("rest", "SKIP").is_some());
107124
}
108125
}
109126

127+
#[cfg(test)]
128+
mod tls_mode_tests {
129+
use super::validate_tls_mode;
130+
131+
#[test]
132+
fn omitted_and_skip_are_accepted() {
133+
for tls in ["", "skip"] {
134+
assert_eq!(validate_tls_mode(tls), None, "tls: {tls:?}");
135+
}
136+
}
137+
138+
#[test]
139+
fn every_other_value_is_rejected() {
140+
for tls in ["terminate", "passthrough", "bogus"] {
141+
let error = validate_tls_mode(tls).expect("must be rejected");
142+
assert!(error.contains(&format!("unsupported tls value '{tls}'")));
143+
assert!(error.contains("remove the tls field"));
144+
}
145+
}
146+
}
147+
110148
/// Fields extracted from an endpoint definition needed for L7 semantic
111149
/// validation. Both profile lint and the runtime validator construct this
112150
/// from their own data representation.
@@ -120,6 +158,9 @@ pub struct L7EndpointFields<'a> {
120158
/// means no access preset.
121159
pub access: &'a str,
122160

161+
/// TLS handling as authored. Empty string means the default.
162+
pub tls: &'a str,
163+
123164
/// `true` when the endpoint has a non-empty rules list.
124165
pub has_rules: bool,
125166

@@ -134,8 +175,8 @@ pub struct L7EndpointFields<'a> {
134175
pub allow_all_known_mcp_methods: bool,
135176
}
136177

137-
/// Validate the semantic consistency of an L7 endpoint's field
138-
/// combination.
178+
/// Validate an L7 endpoint's individual field values and their semantic
179+
/// consistency with each other.
139180
///
140181
/// Returns a list of error message strings. An empty list means the
141182
/// endpoint passes validation. Messages are bare — callers prepend
@@ -226,6 +267,11 @@ pub fn validate_l7_endpoint_semantics(ep: &L7EndpointFields<'_>) -> Vec<String>
226267
errors.push("deny_rules require rules or access to define the base allow set".to_string());
227268
}
228269

270+
// 10. Unsupported tls value.
271+
if let Some(reason) = validate_tls_mode(ep.tls) {
272+
errors.push(reason);
273+
}
274+
229275
errors
230276
}
231277

@@ -237,6 +283,7 @@ mod tests {
237283
L7EndpointFields {
238284
protocol: "rest",
239285
access: "read-only",
286+
tls: "",
240287
has_rules: false,
241288
has_deny_rules: false,
242289
rules_would_deny_all: false,
@@ -255,6 +302,7 @@ mod tests {
255302
let ep = L7EndpointFields {
256303
protocol: "ftp",
257304
access: "",
305+
tls: "",
258306
has_rules: false,
259307
has_deny_rules: false,
260308
rules_would_deny_all: false,
@@ -269,6 +317,7 @@ mod tests {
269317
let ep = L7EndpointFields {
270318
protocol: "rest",
271319
access: "full",
320+
tls: "",
272321
has_rules: true,
273322
has_deny_rules: false,
274323
rules_would_deny_all: false,
@@ -283,6 +332,7 @@ mod tests {
283332
let ep = L7EndpointFields {
284333
protocol: "json-rpc",
285334
access: "full",
335+
tls: "",
286336
has_rules: false,
287337
has_deny_rules: false,
288338
rules_would_deny_all: false,
@@ -301,6 +351,7 @@ mod tests {
301351
let ep = L7EndpointFields {
302352
protocol: "mcp",
303353
access: "full",
354+
tls: "",
304355
has_rules: false,
305356
has_deny_rules: false,
306357
rules_would_deny_all: false,
@@ -319,6 +370,7 @@ mod tests {
319370
let ep = L7EndpointFields {
320371
protocol: "json-rpc",
321372
access: "",
373+
tls: "",
322374
has_rules: false,
323375
has_deny_rules: false,
324376
rules_would_deny_all: false,
@@ -333,6 +385,7 @@ mod tests {
333385
let ep = L7EndpointFields {
334386
protocol: "rest",
335387
access: "",
388+
tls: "",
336389
has_rules: false,
337390
has_deny_rules: false,
338391
rules_would_deny_all: false,
@@ -351,6 +404,7 @@ mod tests {
351404
let ep = L7EndpointFields {
352405
protocol: "mcp",
353406
access: "",
407+
tls: "",
354408
has_rules: false,
355409
has_deny_rules: false,
356410
rules_would_deny_all: false,
@@ -365,6 +419,7 @@ mod tests {
365419
let ep = L7EndpointFields {
366420
protocol: "mcp",
367421
access: "",
422+
tls: "",
368423
has_rules: false,
369424
has_deny_rules: false,
370425
rules_would_deny_all: false,
@@ -379,6 +434,7 @@ mod tests {
379434
let ep = L7EndpointFields {
380435
protocol: "rest",
381436
access: "",
437+
tls: "",
382438
has_rules: true,
383439
has_deny_rules: false,
384440
rules_would_deny_all: true,
@@ -393,6 +449,7 @@ mod tests {
393449
let ep = L7EndpointFields {
394450
protocol: "mcp",
395451
access: "",
452+
tls: "",
396453
has_rules: true,
397454
has_deny_rules: false,
398455
rules_would_deny_all: true,
@@ -410,6 +467,7 @@ mod tests {
410467
let ep = L7EndpointFields {
411468
protocol: "",
412469
access: "",
470+
tls: "",
413471
has_rules: false,
414472
has_deny_rules: true,
415473
rules_would_deny_all: false,
@@ -428,6 +486,7 @@ mod tests {
428486
let ep = L7EndpointFields {
429487
protocol: "rest",
430488
access: "",
489+
tls: "",
431490
has_rules: false,
432491
has_deny_rules: true,
433492
rules_would_deny_all: false,
@@ -446,6 +505,7 @@ mod tests {
446505
let ep = L7EndpointFields {
447506
protocol: "",
448507
access: "",
508+
tls: "",
449509
has_rules: false,
450510
has_deny_rules: false,
451511
rules_would_deny_all: false,
@@ -460,6 +520,7 @@ mod tests {
460520
let ep = L7EndpointFields {
461521
protocol: "tcp",
462522
access: "",
523+
tls: "",
463524
has_rules: false,
464525
has_deny_rules: false,
465526
rules_would_deny_all: false,
@@ -476,6 +537,7 @@ mod tests {
476537
let ep = L7EndpointFields {
477538
protocol: "tcp",
478539
access: "full",
540+
tls: "",
479541
has_rules: false,
480542
has_deny_rules: false,
481543
rules_would_deny_all: false,
@@ -520,6 +582,7 @@ mod tests {
520582
let ep = L7EndpointFields {
521583
protocol: "json-rpc",
522584
access: "full",
585+
tls: "",
523586
has_rules: false,
524587
has_deny_rules: false,
525588
rules_would_deny_all: false,
@@ -541,6 +604,7 @@ mod tests {
541604
let ep = L7EndpointFields {
542605
protocol: "json-rpc",
543606
access: "",
607+
tls: "",
544608
has_rules: false,
545609
has_deny_rules: false,
546610
rules_would_deny_all: false,

crates/openshell-policy/src/lib.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ pub use compose::{
3838
};
3939
pub use l7_validate::{
4040
L7EndpointFields, L7Protocol, agent_authored_transport_rejection,
41-
validate_explicit_tcp_additional_fields, validate_l7_endpoint_semantics,
41+
validate_explicit_tcp_additional_fields, validate_l7_endpoint_semantics, validate_tls_mode,
4242
};
4343
pub use merge::{
4444
PolicyMergeError, PolicyMergeOp, PolicyMergeResult, PolicyMergeWarning,
@@ -1513,6 +1513,7 @@ fn validate_sandbox_policy_with_mcp_presence(
15131513
let fields = L7EndpointFields {
15141514
protocol: &ep.protocol,
15151515
access: &ep.access,
1516+
tls: &ep.tls,
15161517
has_rules: !ep.rules.is_empty(),
15171518
has_deny_rules: !ep.deny_rules.is_empty(),
15181519
rules_would_deny_all,
@@ -3871,6 +3872,35 @@ network_policies:
38713872
assert!(validate_sandbox_policy(&policy).is_ok());
38723873
}
38733874

3875+
#[test]
3876+
fn validate_rejects_unknown_tls_mode() {
3877+
let mut policy = restrictive_default_policy();
3878+
policy.network_policies.insert(
3879+
"api".into(),
3880+
NetworkPolicyRule {
3881+
name: "api".into(),
3882+
endpoints: vec![NetworkEndpoint {
3883+
host: "api.example.com".into(),
3884+
port: 443,
3885+
tls: "terminate".into(),
3886+
..Default::default()
3887+
}],
3888+
..Default::default()
3889+
},
3890+
);
3891+
3892+
let violations =
3893+
validate_sandbox_policy(&policy).expect_err("unsupported tls value must be rejected");
3894+
assert!(
3895+
violations.iter().any(|v| matches!(
3896+
v,
3897+
PolicyViolation::InvalidL7Endpoint { reason, .. }
3898+
if reason.contains("unsupported tls value 'terminate'")
3899+
)),
3900+
"should be rejected: {violations:?}"
3901+
);
3902+
}
3903+
38743904
#[test]
38753905
fn validate_rejects_sigv4_no_body_without_signing_service() {
38763906
let mut policy = restrictive_default_policy();

crates/openshell-policy/src/merge.rs

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,7 @@ fn endpoint_attributes_cover(loaded: &NetworkEndpoint, proposed: &NetworkEndpoin
697697
if !proposed.protocol.is_empty() && !protocols_match(&loaded.protocol, &proposed.protocol) {
698698
return false;
699699
}
700-
if !proposed.tls.is_empty() && effective_tls(&loaded.tls) != effective_tls(&proposed.tls) {
700+
if !proposed.tls.is_empty() && loaded.tls != proposed.tls {
701701
return false;
702702
}
703703
if !proposed.enforcement.is_empty()
@@ -801,13 +801,6 @@ fn protocols_match(left: &str, right: &str) -> bool {
801801
}
802802
}
803803

804-
fn effective_tls(value: &str) -> &str {
805-
match value {
806-
"" | "terminate" | "passthrough" => "auto",
807-
value => value,
808-
}
809-
}
810-
811804
fn effective_enforcement(value: &str) -> &str {
812805
if value.is_empty() { "audit" } else { value }
813806
}
@@ -3537,7 +3530,6 @@ mod tests {
35373530
assert!(!policy_covers_rule(&loaded, &different_body));
35383531

35393532
let mut explicit_defaults = loaded_endpoint;
3540-
explicit_defaults.tls = "passthrough".to_string();
35413533
explicit_defaults.enforcement = "audit".to_string();
35423534
let runtime_defaults = rule_with_authorizations(
35433535
"proposed",
@@ -3546,13 +3538,17 @@ mod tests {
35463538
);
35473539
assert!(policy_covers_rule(&loaded, &runtime_defaults));
35483540

3549-
explicit_defaults.tls = "terminate".to_string();
3550-
let legacy_terminate = rule_with_authorizations(
3551-
"proposed",
3552-
vec![explicit_defaults.clone()],
3553-
&["/usr/bin/client"],
3554-
);
3555-
assert!(policy_covers_rule(&loaded, &legacy_terminate));
3541+
// Coverage validates both sides, so a legacy spelling fails closed
3542+
// rather than comparing equal to the automatic default.
3543+
for legacy in ["terminate", "passthrough"] {
3544+
explicit_defaults.tls = legacy.to_string();
3545+
let legacy_rule = rule_with_authorizations(
3546+
"proposed",
3547+
vec![explicit_defaults.clone()],
3548+
&["/usr/bin/client"],
3549+
);
3550+
assert!(!policy_covers_rule(&loaded, &legacy_rule), "tls: {legacy}");
3551+
}
35563552

35573553
explicit_defaults.tls = "skip".to_string();
35583554
let skip_tls = rule_with_authorizations(

0 commit comments

Comments
 (0)