Skip to content

Commit 024150e

Browse files
authored
feat(policy): add validation layer to reject unsafe sandbox policies (#135)
* feat(policy): add validation layer to reject unsafe sandbox policies Add policy validation that checks for root process identity, path traversal sequences, overly broad filesystem paths, and exceeding filesystem rule limits. Validation runs at three entry points: disk-loaded YAML policies (fallback to restrictive default on violation), gRPC CreateSandbox, and gRPC UpdateSandboxPolicy (returns INVALID_ARGUMENT). Filesystem paths are normalized before storage to collapse traversal components. Closes NVIDIA#33 * fix(e2e): correct policy update test to match immutable field behavior The update policy test was asserting on validation errors for fields (process, filesystem) that are immutable on live sandboxes. The server rejects changes to these fields before validation runs. Updated the test to verify the immutability guard instead. --------- Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
1 parent ff99fec commit 024150e

File tree

7 files changed

+671
-7
lines changed

7 files changed

+671
-7
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/navigator-policy/src/lib.rs

Lines changed: 358 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
//! these types, ensuring round-trip fidelity.
1111
1212
use std::collections::{BTreeMap, HashMap};
13+
use std::fmt;
14+
use std::path::Path;
1315

1416
use miette::{IntoDiagnostic, Result, WrapErr};
1517
use navigator_core::proto::{
@@ -359,12 +361,12 @@ pub fn serialize_sandbox_policy(policy: &SandboxPolicy) -> Result<String> {
359361
/// default.
360362
pub fn load_sandbox_policy(cli_path: Option<&str>) -> Result<Option<SandboxPolicy>> {
361363
let contents = if let Some(p) = cli_path {
362-
let path = std::path::Path::new(p);
364+
let path = Path::new(p);
363365
std::fs::read_to_string(path)
364366
.into_diagnostic()
365367
.wrap_err_with(|| format!("failed to read sandbox policy from {}", path.display()))?
366368
} else if let Ok(policy_path) = std::env::var("NEMOCLAW_SANDBOX_POLICY") {
367-
let path = std::path::Path::new(&policy_path);
369+
let path = Path::new(&policy_path);
368370
std::fs::read_to_string(path)
369371
.into_diagnostic()
370372
.wrap_err_with(|| format!("failed to read sandbox policy from {}", path.display()))?
@@ -425,6 +427,195 @@ pub fn clear_process_identity(policy: &mut SandboxPolicy) {
425427
}
426428
}
427429

430+
// ---------------------------------------------------------------------------
431+
// Policy safety validation
432+
// ---------------------------------------------------------------------------
433+
434+
/// Maximum number of filesystem paths (`read_only` + `read_write` combined).
435+
const MAX_FILESYSTEM_PATHS: usize = 256;
436+
437+
/// Maximum length of any single filesystem path string.
438+
const MAX_PATH_LENGTH: usize = 4096;
439+
440+
/// A safety violation found in a sandbox policy.
441+
#[derive(Debug, Clone, PartialEq, Eq)]
442+
pub enum PolicyViolation {
443+
/// `run_as_user` or `run_as_group` is "root" or "0".
444+
RootProcessIdentity { field: &'static str, value: String },
445+
/// A filesystem path contains `..` components.
446+
PathTraversal { path: String },
447+
/// A filesystem path is not absolute (does not start with `/`).
448+
RelativePath { path: String },
449+
/// A read-write filesystem path is overly broad (e.g. `/`).
450+
OverlyBroadPath { path: String },
451+
/// A filesystem path exceeds the maximum allowed length.
452+
FieldTooLong { path: String, length: usize },
453+
/// Too many filesystem paths in the policy.
454+
TooManyPaths { count: usize },
455+
}
456+
457+
impl fmt::Display for PolicyViolation {
458+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459+
match self {
460+
Self::RootProcessIdentity { field, value } => {
461+
write!(f, "{field} cannot be '{value}' (root is not allowed)")
462+
}
463+
Self::PathTraversal { path } => {
464+
write!(f, "path contains '..' traversal component: {path}")
465+
}
466+
Self::RelativePath { path } => {
467+
write!(f, "path must be absolute (start with '/'): {path}")
468+
}
469+
Self::OverlyBroadPath { path } => {
470+
write!(f, "read-write path is overly broad: {path}")
471+
}
472+
Self::FieldTooLong { path, length } => {
473+
write!(
474+
f,
475+
"path exceeds maximum length ({length} > {MAX_PATH_LENGTH}): {path}"
476+
)
477+
}
478+
Self::TooManyPaths { count } => {
479+
write!(
480+
f,
481+
"too many filesystem paths ({count} > {MAX_FILESYSTEM_PATHS})"
482+
)
483+
}
484+
}
485+
}
486+
}
487+
488+
/// Validate that a sandbox policy does not contain unsafe content.
489+
///
490+
/// Returns `Ok(())` if the policy is safe, or `Err(violations)` listing all
491+
/// safety violations found. Callers decide how to handle violations (hard
492+
/// error vs. logged warning).
493+
///
494+
/// Checks performed:
495+
/// - `run_as_user` / `run_as_group` must not be "root" or "0"
496+
/// - Filesystem paths must be absolute (start with `/`)
497+
/// - Filesystem paths must not contain `..` components
498+
/// - Read-write paths must not be overly broad (just `/`)
499+
/// - Individual path lengths must not exceed [`MAX_PATH_LENGTH`]
500+
/// - Total path count must not exceed [`MAX_FILESYSTEM_PATHS`]
501+
pub fn validate_sandbox_policy(
502+
policy: &SandboxPolicy,
503+
) -> std::result::Result<(), Vec<PolicyViolation>> {
504+
let mut violations = Vec::new();
505+
506+
// Check process identity
507+
if let Some(ref process) = policy.process {
508+
if is_root_identity(&process.run_as_user) {
509+
violations.push(PolicyViolation::RootProcessIdentity {
510+
field: "run_as_user",
511+
value: process.run_as_user.clone(),
512+
});
513+
}
514+
if is_root_identity(&process.run_as_group) {
515+
violations.push(PolicyViolation::RootProcessIdentity {
516+
field: "run_as_group",
517+
value: process.run_as_group.clone(),
518+
});
519+
}
520+
}
521+
522+
// Check filesystem paths
523+
if let Some(ref fs) = policy.filesystem {
524+
let total_paths = fs.read_only.len() + fs.read_write.len();
525+
if total_paths > MAX_FILESYSTEM_PATHS {
526+
violations.push(PolicyViolation::TooManyPaths { count: total_paths });
527+
}
528+
529+
for path_str in fs.read_only.iter().chain(fs.read_write.iter()) {
530+
if path_str.len() > MAX_PATH_LENGTH {
531+
violations.push(PolicyViolation::FieldTooLong {
532+
path: truncate_for_display(path_str),
533+
length: path_str.len(),
534+
});
535+
continue;
536+
}
537+
538+
let path = Path::new(path_str);
539+
540+
if !path.has_root() {
541+
violations.push(PolicyViolation::RelativePath {
542+
path: path_str.clone(),
543+
});
544+
}
545+
546+
if path
547+
.components()
548+
.any(|c| matches!(c, std::path::Component::ParentDir))
549+
{
550+
violations.push(PolicyViolation::PathTraversal {
551+
path: path_str.clone(),
552+
});
553+
}
554+
}
555+
556+
// Only reject "/" as read-write (overly broad)
557+
for path_str in &fs.read_write {
558+
let normalized = path_str.trim_end_matches('/');
559+
if normalized.is_empty() {
560+
// Path is "/" or "///" etc.
561+
violations.push(PolicyViolation::OverlyBroadPath {
562+
path: path_str.clone(),
563+
});
564+
}
565+
}
566+
}
567+
568+
if violations.is_empty() {
569+
Ok(())
570+
} else {
571+
Err(violations)
572+
}
573+
}
574+
575+
/// Check if a user/group identity string refers to root.
576+
fn is_root_identity(value: &str) -> bool {
577+
if value.is_empty() {
578+
return false;
579+
}
580+
let trimmed = value.trim();
581+
trimmed == "root" || trimmed == "0"
582+
}
583+
584+
/// Truncate a string for safe inclusion in error messages.
585+
fn truncate_for_display(s: &str) -> String {
586+
if s.len() <= 80 {
587+
s.to_string()
588+
} else {
589+
format!("{}...", &s[..77])
590+
}
591+
}
592+
593+
/// Normalize a filesystem path by collapsing redundant separators
594+
/// and removing trailing slashes, without requiring the path to exist on disk.
595+
///
596+
/// This is a lexical normalization only — it does NOT resolve symlinks or
597+
/// check the filesystem.
598+
pub fn normalize_path(path: &str) -> String {
599+
use std::path::Component;
600+
601+
let p = Path::new(path);
602+
let mut normalized = std::path::PathBuf::new();
603+
for component in p.components() {
604+
match component {
605+
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
606+
#[allow(clippy::path_buf_push_overwrite)]
607+
Component::RootDir => normalized.push("/"),
608+
Component::CurDir => {} // skip "."
609+
Component::ParentDir => {
610+
// Keep ".." — validation will catch it separately
611+
normalized.push("..");
612+
}
613+
Component::Normal(c) => normalized.push(c),
614+
}
615+
}
616+
normalized.to_string_lossy().to_string()
617+
}
618+
428619
// ---------------------------------------------------------------------------
429620
// Tests
430621
// ---------------------------------------------------------------------------
@@ -635,4 +826,169 @@ network_policies:
635826
fn container_policy_path_is_expected() {
636827
assert_eq!(CONTAINER_POLICY_PATH, "/etc/navigator/policy.yaml");
637828
}
829+
830+
// ---- Policy validation tests ----
831+
832+
#[test]
833+
fn validate_rejects_root_run_as_user() {
834+
let mut policy = restrictive_default_policy();
835+
policy.process = Some(ProcessPolicy {
836+
run_as_user: "root".into(),
837+
run_as_group: "sandbox".into(),
838+
});
839+
let violations = validate_sandbox_policy(&policy).unwrap_err();
840+
assert!(violations.iter().any(|v| matches!(
841+
v,
842+
PolicyViolation::RootProcessIdentity {
843+
field: "run_as_user",
844+
..
845+
}
846+
)));
847+
}
848+
849+
#[test]
850+
fn validate_rejects_uid_zero() {
851+
let mut policy = restrictive_default_policy();
852+
policy.process = Some(ProcessPolicy {
853+
run_as_user: "0".into(),
854+
run_as_group: "0".into(),
855+
});
856+
let violations = validate_sandbox_policy(&policy).unwrap_err();
857+
assert_eq!(violations.len(), 2);
858+
}
859+
860+
#[test]
861+
fn validate_rejects_path_traversal() {
862+
let mut policy = restrictive_default_policy();
863+
policy.filesystem = Some(FilesystemPolicy {
864+
include_workdir: true,
865+
read_only: vec!["/usr/../etc/shadow".into()],
866+
read_write: vec!["/tmp".into()],
867+
});
868+
let violations = validate_sandbox_policy(&policy).unwrap_err();
869+
assert!(
870+
violations
871+
.iter()
872+
.any(|v| matches!(v, PolicyViolation::PathTraversal { .. }))
873+
);
874+
}
875+
876+
#[test]
877+
fn validate_rejects_relative_paths() {
878+
let mut policy = restrictive_default_policy();
879+
policy.filesystem = Some(FilesystemPolicy {
880+
include_workdir: true,
881+
read_only: vec!["usr/lib".into()],
882+
read_write: vec!["/tmp".into()],
883+
});
884+
let violations = validate_sandbox_policy(&policy).unwrap_err();
885+
assert!(
886+
violations
887+
.iter()
888+
.any(|v| matches!(v, PolicyViolation::RelativePath { .. }))
889+
);
890+
}
891+
892+
#[test]
893+
fn validate_rejects_overly_broad_read_write_path() {
894+
let mut policy = restrictive_default_policy();
895+
policy.filesystem = Some(FilesystemPolicy {
896+
include_workdir: true,
897+
read_only: vec!["/usr".into()],
898+
read_write: vec!["/".into()],
899+
});
900+
let violations = validate_sandbox_policy(&policy).unwrap_err();
901+
assert!(
902+
violations
903+
.iter()
904+
.any(|v| matches!(v, PolicyViolation::OverlyBroadPath { .. }))
905+
);
906+
}
907+
908+
#[test]
909+
fn validate_accepts_valid_policy() {
910+
let policy = restrictive_default_policy();
911+
assert!(validate_sandbox_policy(&policy).is_ok());
912+
}
913+
914+
#[test]
915+
fn validate_accepts_empty_process() {
916+
let policy = SandboxPolicy {
917+
version: 1,
918+
process: None,
919+
filesystem: None,
920+
landlock: None,
921+
network_policies: HashMap::new(),
922+
inference: None,
923+
};
924+
assert!(validate_sandbox_policy(&policy).is_ok());
925+
}
926+
927+
#[test]
928+
fn validate_accepts_empty_run_as_user() {
929+
let mut policy = restrictive_default_policy();
930+
policy.process = Some(ProcessPolicy {
931+
run_as_user: String::new(),
932+
run_as_group: String::new(),
933+
});
934+
assert!(validate_sandbox_policy(&policy).is_ok());
935+
}
936+
937+
#[test]
938+
fn validate_rejects_too_many_paths() {
939+
let mut policy = restrictive_default_policy();
940+
let many_paths: Vec<String> = (0..300).map(|i| format!("/path/{i}")).collect();
941+
policy.filesystem = Some(FilesystemPolicy {
942+
include_workdir: true,
943+
read_only: many_paths,
944+
read_write: vec!["/tmp".into()],
945+
});
946+
let violations = validate_sandbox_policy(&policy).unwrap_err();
947+
assert!(
948+
violations
949+
.iter()
950+
.any(|v| matches!(v, PolicyViolation::TooManyPaths { .. }))
951+
);
952+
}
953+
954+
#[test]
955+
fn validate_rejects_path_too_long() {
956+
let mut policy = restrictive_default_policy();
957+
let long_path = format!("/{}", "a".repeat(5000));
958+
policy.filesystem = Some(FilesystemPolicy {
959+
include_workdir: true,
960+
read_only: vec![long_path],
961+
read_write: vec!["/tmp".into()],
962+
});
963+
let violations = validate_sandbox_policy(&policy).unwrap_err();
964+
assert!(
965+
violations
966+
.iter()
967+
.any(|v| matches!(v, PolicyViolation::FieldTooLong { .. }))
968+
);
969+
}
970+
971+
#[test]
972+
fn normalize_path_collapses_separators() {
973+
assert_eq!(normalize_path("/usr//lib"), "/usr/lib");
974+
assert_eq!(normalize_path("/usr/./lib"), "/usr/lib");
975+
assert_eq!(normalize_path("/tmp/"), "/tmp");
976+
}
977+
978+
#[test]
979+
fn normalize_path_preserves_parent_dir() {
980+
// normalize_path preserves ".." — validation catches it separately
981+
assert_eq!(normalize_path("/usr/../etc"), "/usr/../etc");
982+
}
983+
984+
#[test]
985+
fn policy_violation_display() {
986+
let v = PolicyViolation::RootProcessIdentity {
987+
field: "run_as_user",
988+
value: "root".into(),
989+
};
990+
let s = format!("{v}");
991+
assert!(s.contains("root"));
992+
assert!(s.contains("run_as_user"));
993+
}
638994
}

0 commit comments

Comments
 (0)