Skip to content

Commit 7f4bd49

Browse files
authored
fix(policy): harden landlock.compatibility validation (#2541)
* fix(policy): reject invalid landlock.compatibility values at parse time Signed-off-by: Artem Lytvyn <alytvyn@redhat.com> * fix(policy): abort sandbox startup when hard_requirement has no filesystem paths Signed-off-by: Artem Lytvyn <alytvyn@redhat.com> * fix(policy): validate landlock.compatibility at gateway and fix zero-path logging Signed-off-by: Artem Lytvyn <alytvyn@redhat.com> * fix(policy): reject invalid landlock.compatibility on serialization Signed-off-by: Artem Lytvyn <alytvyn@redhat.com> * fix: fixed linting error Signed-off-by: Artem Lytvyn <alytvyn@redhat.com> --------- Signed-off-by: Artem Lytvyn <alytvyn@redhat.com>
1 parent 48c449d commit 7f4bd49

5 files changed

Lines changed: 336 additions & 32 deletions

File tree

‎crates/openshell-core/src/policy.rs‎

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,19 @@ pub enum LandlockCompatibility {
9292
HardRequirement,
9393
}
9494

95+
/// Accepted `landlock.compatibility` values in their proto string form.
96+
///
97+
/// Single source of truth shared by YAML parsing, proto→runtime conversion,
98+
/// and gateway policy validation so the accepted set cannot drift.
99+
pub const LANDLOCK_COMPATIBILITY_VALUES: [&str; 2] = ["best_effort", "hard_requirement"];
100+
101+
/// Returns `true` if `value` is an accepted `landlock.compatibility` string.
102+
///
103+
/// The empty string is accepted and defaults to `best_effort`.
104+
pub fn is_valid_landlock_compatibility(value: &str) -> bool {
105+
value.is_empty() || LANDLOCK_COMPATIBILITY_VALUES.contains(&value)
106+
}
107+
95108
// ============================================================================
96109
// Proto to Rust type conversions
97110
// ============================================================================
@@ -114,7 +127,11 @@ impl TryFrom<ProtoSandboxPolicy> for SandboxPolicy {
114127
.map(FilesystemPolicy::from)
115128
.unwrap_or_default(),
116129
network,
117-
landlock: proto.landlock.map(LandlockPolicy::from).unwrap_or_default(),
130+
landlock: proto
131+
.landlock
132+
.map(LandlockPolicy::try_from)
133+
.transpose()?
134+
.unwrap_or_default(),
118135
process: proto.process.map(ProcessPolicy::from).unwrap_or_default(),
119136
})
120137
}
@@ -138,14 +155,20 @@ impl From<ProtoFilesystemPolicy> for FilesystemPolicy {
138155
}
139156
}
140157

141-
impl From<ProtoLandlockPolicy> for LandlockPolicy {
142-
fn from(proto: ProtoLandlockPolicy) -> Self {
143-
let compatibility = if proto.compatibility == "hard_requirement" {
144-
LandlockCompatibility::HardRequirement
145-
} else {
146-
LandlockCompatibility::BestEffort
158+
impl TryFrom<ProtoLandlockPolicy> for LandlockPolicy {
159+
type Error = miette::Error;
160+
161+
fn try_from(proto: ProtoLandlockPolicy) -> Result<Self, Self::Error> {
162+
let compatibility = match proto.compatibility.as_str() {
163+
"best_effort" | "" => LandlockCompatibility::BestEffort,
164+
"hard_requirement" => LandlockCompatibility::HardRequirement,
165+
otherwise => miette::bail!(
166+
"invalid landlock.compatibility {:?}; accepted: {}",
167+
otherwise,
168+
LANDLOCK_COMPATIBILITY_VALUES.join(", ")
169+
),
147170
};
148-
Self { compatibility }
171+
Ok(Self { compatibility })
149172
}
150173
}
151174

@@ -165,3 +188,49 @@ impl From<ProtoProcessPolicy> for ProcessPolicy {
165188
}
166189
}
167190
}
191+
192+
#[cfg(test)]
193+
mod tests {
194+
use super::*;
195+
196+
#[test]
197+
fn try_from_maps_known_compatibility_values() {
198+
for (input, expected) in [
199+
("", LandlockCompatibility::BestEffort),
200+
("best_effort", LandlockCompatibility::BestEffort),
201+
("hard_requirement", LandlockCompatibility::HardRequirement),
202+
] {
203+
let proto = ProtoLandlockPolicy {
204+
compatibility: input.into(),
205+
};
206+
let policy = LandlockPolicy::try_from(proto).expect("should convert");
207+
assert_eq!(
208+
std::mem::discriminant(&policy.compatibility),
209+
std::mem::discriminant(&expected),
210+
"input {input:?} mapped to unexpected variant",
211+
);
212+
}
213+
}
214+
215+
#[test]
216+
fn try_from_rejects_invalid_compatibility() {
217+
let proto = ProtoLandlockPolicy {
218+
compatibility: "hard-requirement".into(),
219+
};
220+
let err = LandlockPolicy::try_from(proto).expect_err("should reject");
221+
let msg = format!("{err:?}");
222+
assert!(
223+
msg.contains("best_effort") && msg.contains("hard_requirement"),
224+
"error should list accepted values, got: {msg}",
225+
);
226+
}
227+
228+
#[test]
229+
fn is_valid_landlock_compatibility_accepts_empty_and_known() {
230+
assert!(is_valid_landlock_compatibility(""));
231+
assert!(is_valid_landlock_compatibility("best_effort"));
232+
assert!(is_valid_landlock_compatibility("hard_requirement"));
233+
assert!(!is_valid_landlock_compatibility("nope"));
234+
assert!(!is_valid_landlock_compatibility("BestEffort"));
235+
}
236+
}

‎crates/openshell-policy/src/lib.rs‎

Lines changed: 128 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,19 @@ struct FilesystemDef {
8080
read_write: Vec<String>,
8181
}
8282

83+
#[derive(Debug, Default, Serialize, Deserialize)]
84+
#[serde(rename_all = "snake_case")]
85+
enum LandlockCompatibilityDef {
86+
#[default]
87+
BestEffort,
88+
HardRequirement,
89+
}
90+
8391
#[derive(Debug, Serialize, Deserialize)]
8492
#[serde(deny_unknown_fields)]
8593
struct LandlockDef {
86-
#[serde(default, skip_serializing_if = "String::is_empty")]
87-
compatibility: String,
94+
#[serde(default)]
95+
compatibility: LandlockCompatibilityDef,
8896
}
8997

9098
#[derive(Debug, Serialize, Deserialize)]
@@ -918,7 +926,10 @@ fn to_proto(raw: PolicyFile) -> Result<SandboxPolicy> {
918926
read_write: fs.read_write,
919927
}),
920928
landlock: raw.landlock.map(|ll| LandlockPolicy {
921-
compatibility: ll.compatibility,
929+
compatibility: match ll.compatibility {
930+
LandlockCompatibilityDef::BestEffort => "best_effort".to_string(),
931+
LandlockCompatibilityDef::HardRequirement => "hard_requirement".to_string(),
932+
},
922933
}),
923934
process: raw.process.map(|p| ProcessPolicy {
924935
run_as_user: p.run_as_user,
@@ -933,16 +944,28 @@ fn to_proto(raw: PolicyFile) -> Result<SandboxPolicy> {
933944
// Proto → YAML conversion
934945
// ---------------------------------------------------------------------------
935946

936-
fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
947+
fn from_proto(policy: &SandboxPolicy) -> Result<PolicyFile> {
937948
let filesystem_policy = policy.filesystem.as_ref().map(|fs| FilesystemDef {
938949
include_workdir: fs.include_workdir,
939950
read_only: fs.read_only.clone(),
940951
read_write: fs.read_write.clone(),
941952
});
942953

943-
let landlock = policy.landlock.as_ref().map(|ll| LandlockDef {
944-
compatibility: ll.compatibility.clone(),
945-
});
954+
let landlock = match policy.landlock.as_ref() {
955+
Some(ll) => {
956+
let compatibility = match ll.compatibility.as_str() {
957+
"hard_requirement" => LandlockCompatibilityDef::HardRequirement,
958+
"best_effort" | "" => LandlockCompatibilityDef::BestEffort,
959+
otherwise => miette::bail!(
960+
"invalid landlock.compatibility {:?}; accepted: {}",
961+
otherwise,
962+
openshell_core::policy::LANDLOCK_COMPATIBILITY_VALUES.join(", ")
963+
),
964+
};
965+
Some(LandlockDef { compatibility })
966+
}
967+
_ => None,
968+
};
946969

947970
let process = policy.process.as_ref().and_then(|p| {
948971
if p.run_as_user.is_empty() && p.run_as_group.is_empty() {
@@ -1066,14 +1089,14 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile {
10661089

10671090
let network_middlewares = middleware::from_proto(&policy.network_middlewares);
10681091

1069-
PolicyFile {
1092+
Ok(PolicyFile {
10701093
version: policy.version,
10711094
filesystem_policy,
10721095
landlock,
10731096
process,
10741097
network_policies,
10751098
network_middlewares,
1076-
}
1099+
})
10771100
}
10781101

10791102
// ---------------------------------------------------------------------------
@@ -1176,7 +1199,7 @@ pub fn parse_sandbox_policy(yaml: &str) -> Result<SandboxPolicy> {
11761199
pub fn serialize_sandbox_policy(policy: &SandboxPolicy) -> Result<String> {
11771200
let canonical = validate_and_canonicalize_mcp_policy_schema(policy.clone())
11781201
.map_err(|error| miette::miette!("cannot serialize invalid sandbox policy: {error}"))?;
1179-
let yaml_repr = from_proto(&canonical);
1202+
let yaml_repr = from_proto(&canonical)?;
11801203
serde_yml::to_string(&yaml_repr)
11811204
.into_diagnostic()
11821205
.wrap_err("failed to serialize policy to YAML")
@@ -1189,7 +1212,7 @@ pub fn serialize_sandbox_policy(policy: &SandboxPolicy) -> Result<String> {
11891212
pub fn sandbox_policy_to_json_value(policy: &SandboxPolicy) -> Result<serde_json::Value> {
11901213
let canonical = validate_and_canonicalize_mcp_policy_schema(policy.clone())
11911214
.map_err(|error| miette::miette!("cannot serialize invalid sandbox policy: {error}"))?;
1192-
let json_repr = from_proto(&canonical);
1215+
let json_repr = from_proto(&canonical)?;
11931216
serde_json::to_value(&json_repr)
11941217
.into_diagnostic()
11951218
.wrap_err("failed to serialize policy to JSON")
@@ -1372,6 +1395,8 @@ pub enum PolicyViolation {
13721395
policy_name: String,
13731396
host: String,
13741397
},
1398+
/// `landlock.compatibility` has an unrecognized value.
1399+
InvalidLandlockCompatibility { value: String },
13751400
/// An effective MCP endpoint has not materialized a protocol revision.
13761401
MissingMcpVersions { policy_name: String, host: String },
13771402
/// A non-MCP endpoint carries MCP-only configuration.
@@ -1554,6 +1579,13 @@ impl fmt::Display for PolicyViolation {
15541579
'{policy_name}' tls: skip endpoint '{host}'"
15551580
)
15561581
}
1582+
Self::InvalidLandlockCompatibility { value } => {
1583+
write!(
1584+
f,
1585+
"invalid landlock.compatibility '{value}'; accepted: {}",
1586+
openshell_core::policy::LANDLOCK_COMPATIBILITY_VALUES.join(", ")
1587+
)
1588+
}
15571589
Self::MissingMcpVersions { policy_name, host } => {
15581590
write!(
15591591
f,
@@ -1650,6 +1682,17 @@ fn validate_sandbox_policy_with_mcp_presence(
16501682
}
16511683
}
16521684

1685+
// Check landlock compatibility mode is a recognized value. Direct gRPC/SDK
1686+
// clients bypass YAML serde validation, so reject invalid values here at the
1687+
// gateway create path rather than deferring rejection to sandbox startup.
1688+
if let Some(ref landlock) = policy.landlock
1689+
&& !openshell_core::policy::is_valid_landlock_compatibility(&landlock.compatibility)
1690+
{
1691+
violations.push(PolicyViolation::InvalidLandlockCompatibility {
1692+
value: landlock.compatibility.clone(),
1693+
});
1694+
}
1695+
16531696
// Check filesystem paths
16541697
if let Some(ref fs) = policy.filesystem {
16551698
let total_paths = fs.read_only.len() + fs.read_write.len();
@@ -3152,6 +3195,80 @@ network_policies:
31523195
assert_eq!(violations.len(), 2);
31533196
}
31543197

3198+
#[test]
3199+
fn parse_rejects_invalid_landlock_compatibility() {
3200+
let err = parse_sandbox_policy("version: 1\nlandlock:\n compatibility: bogus\n")
3201+
.expect_err("should reject invalid YAML enum value");
3202+
let msg = format!("{err:?}");
3203+
assert!(
3204+
msg.contains("best_effort") && msg.contains("hard_requirement"),
3205+
"error should list accepted values, got: {msg}",
3206+
);
3207+
}
3208+
3209+
#[test]
3210+
fn parse_accepts_known_landlock_compatibility() {
3211+
for value in ["best_effort", "hard_requirement"] {
3212+
let yaml = format!("version: 1\nlandlock:\n compatibility: {value}\n");
3213+
let policy = parse_sandbox_policy(&yaml).expect("should parse");
3214+
assert_eq!(
3215+
policy.landlock.as_ref().expect("landlock").compatibility,
3216+
value,
3217+
);
3218+
}
3219+
}
3220+
3221+
#[test]
3222+
fn validate_rejects_invalid_landlock_compatibility_proto() {
3223+
let mut policy = restrictive_default_policy();
3224+
policy.landlock = Some(LandlockPolicy {
3225+
compatibility: "nope".into(),
3226+
});
3227+
let violations = validate_sandbox_policy(&policy).unwrap_err();
3228+
assert!(
3229+
violations
3230+
.iter()
3231+
.any(|v| matches!(v, PolicyViolation::InvalidLandlockCompatibility { .. })),
3232+
"expected InvalidLandlockCompatibility, got: {violations:?}",
3233+
);
3234+
}
3235+
3236+
#[test]
3237+
fn validate_accepts_empty_landlock_compatibility() {
3238+
// Empty string is the proto default and maps to best_effort.
3239+
let mut policy = restrictive_default_policy();
3240+
policy.landlock = Some(LandlockPolicy {
3241+
compatibility: String::new(),
3242+
});
3243+
assert!(validate_sandbox_policy(&policy).is_ok());
3244+
}
3245+
3246+
#[test]
3247+
fn serialize_rejects_invalid_landlock_compatibility() {
3248+
// Old policies persisted before gateway validation can hold invalid
3249+
// values; serialization must error rather than normalize to best_effort.
3250+
let mut policy = restrictive_default_policy();
3251+
policy.landlock = Some(LandlockPolicy {
3252+
compatibility: "hard-requirement".to_string(),
3253+
});
3254+
let err = serialize_sandbox_policy(&policy).expect_err("should reject");
3255+
let msg = format!("{err:?}");
3256+
assert!(
3257+
msg.contains("best_effort") && msg.contains("hard_requirement"),
3258+
"error should list accepted values, got: {msg}",
3259+
);
3260+
}
3261+
3262+
#[test]
3263+
fn serialize_accepts_empty_landlock_compatibility() {
3264+
// Empty string is the proto default; serialize must not error on it.
3265+
let mut policy = restrictive_default_policy();
3266+
policy.landlock = Some(LandlockPolicy {
3267+
compatibility: String::new(),
3268+
});
3269+
assert!(serialize_sandbox_policy(&policy).is_ok());
3270+
}
3271+
31553272
#[test]
31563273
fn validate_rejects_invalid_middleware_control_fields() {
31573274
let cases = [

0 commit comments

Comments
 (0)