Skip to content

Commit b5751ab

Browse files
committed
credential: honor chroot virtual namespace in fs-deny overlap suppression
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent cfca8d2 commit b5751ab

2 files changed

Lines changed: 117 additions & 19 deletions

File tree

crates/sandlock-core/src/credential.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,11 @@ pub fn parse_auth(spec: &str, credential: &str) -> Result<AuthShape, SandboxErro
374374
}
375375
AuthShape::Header { name: name.to_string() }
376376
}
377+
// The de-dup filter matches param names percent-decoded, but not `+`
378+
// (form-encoded space) or the quirks of non-conformant server query
379+
// parsers. Use a plain URL-token name (letters/digits, no spaces, `+`,
380+
// or percent-encoding) so the injected pair reliably replaces the
381+
// child's rather than sitting alongside it.
377382
("query", Some(param)) if !param.is_empty() => AuthShape::Query { param: param.to_string() },
378383
_ => {
379384
return Err(SandboxError::Invalid(format!(

crates/sandlock-core/src/sandbox/builder.rs

Lines changed: 112 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -768,15 +768,19 @@ impl SandboxBuilder {
768768
.chain(self.fs_writable.iter())
769769
.chain(self.fs_mount.iter().map(|(_, host)| host))
770770
.chain(self.chroot.as_ref());
771-
if let Some(grant) =
772-
exposing_grant(std::path::Path::new(path), grants, &self.fs_denied)
773-
{
771+
if let Some(exposure) = exposing_grant(
772+
std::path::Path::new(path),
773+
grants,
774+
&self.fs_denied,
775+
self.chroot.as_deref(),
776+
) {
774777
eprintln!(
775778
"sandlock: warning: credential file {} is inside the sandbox grant {}; \
776779
the sandboxed child can read the secret directly; keep it outside every \
777-
fs grant (or add an fs-deny for it)",
780+
fs grant (or add `--fs-deny {}` to close it)",
778781
path,
779-
grant.display()
782+
exposure.grant.display(),
783+
exposure.deny_target.display(),
780784
);
781785
}
782786
}
@@ -905,24 +909,75 @@ impl SandboxBuilder {
905909
}
906910
}
907911

912+
/// An fs grant that exposes a credential file to the sandboxed child, with the
913+
/// path an operator should hand `--fs-deny` to actually close the hole.
914+
struct Exposure {
915+
/// The first fs grant that reaches the secret (named in the diagnostic).
916+
grant: std::path::PathBuf,
917+
/// What `--fs-deny` must target to suppress this: the host path normally,
918+
/// but the in-jail (virtual) path under chroot, where the notif layer
919+
/// matches `--fs-deny` values as virtual prefixes (`ChrootCtx::is_denied`).
920+
deny_target: std::path::PathBuf,
921+
}
922+
908923
/// The first fs grant that exposes `secret` to the sandboxed child, or `None`
909924
/// when no grant reaches it or an fs-deny covers it (the deny closes the hole,
910925
/// so no warning is due). Best-effort: canonicalize where possible.
926+
///
927+
/// `chroot` is the jail root when running chrooted. In that mode `--fs-deny`
928+
/// values are matched as in-jail (virtual) prefixes, not host paths, so the
929+
/// deny suppression and the advised deny path are computed in the virtual
930+
/// namespace: a host-path deny like `/jail/etc/token` would silence the warning
931+
/// here yet never fire at runtime (the child opens `/etc/token`).
911932
fn exposing_grant<'a>(
912933
secret: &std::path::Path,
913934
grants: impl Iterator<Item = &'a std::path::PathBuf>,
914935
denies: &[std::path::PathBuf],
915-
) -> Option<std::path::PathBuf> {
936+
chroot: Option<&std::path::Path>,
937+
) -> Option<Exposure> {
916938
let secret_abs = secret.canonicalize();
917939
let secret_ref = secret_abs.as_deref().unwrap_or(secret);
918-
let covered_by = |p: &std::path::PathBuf| {
919-
let abs = p.canonicalize();
920-
secret_ref.starts_with(abs.as_deref().unwrap_or(p.as_path()))
940+
941+
// Whether the child's view of the secret is host-native or in-jail, and the
942+
// path a matching `--fs-deny` targets. Under chroot the secret at host
943+
// `<root>/etc/token` appears to the child at `/etc/token`; a deny is matched
944+
// against that virtual path, so translate before testing.
945+
let (deny_target, deny_is_virtual) = match chroot {
946+
Some(root) => {
947+
let root_abs = root.canonicalize();
948+
let root_ref = root_abs.as_deref().unwrap_or(root);
949+
match secret_ref.strip_prefix(root_ref) {
950+
Ok(sub) => (std::path::Path::new("/").join(sub), true),
951+
// Secret lives outside the jail: chroot can't expose it, but a
952+
// host grant still might, so keep host-path deny semantics.
953+
Err(_) => (secret_ref.to_path_buf(), false),
954+
}
955+
}
956+
None => (secret_ref.to_path_buf(), false),
921957
};
922-
if denies.iter().any(&covered_by) {
958+
959+
let covered_by_deny = |d: &std::path::PathBuf| {
960+
if deny_is_virtual {
961+
// Virtual prefix match mirroring `ChrootCtx::is_denied`. No
962+
// canonicalize: an in-jail path need not exist on the host.
963+
deny_target.starts_with(d)
964+
} else {
965+
let abs = d.canonicalize();
966+
deny_target.starts_with(abs.as_deref().unwrap_or(d.as_path()))
967+
}
968+
};
969+
if denies.iter().any(covered_by_deny) {
923970
return None;
924971
}
925-
grants.into_iter().find(|g| covered_by(g)).cloned()
972+
973+
// Grant overlap stays host-native: the file physically sits inside the
974+
// grant (chroot root, bind-mount host dir, or read/write grant).
975+
let covered_by_grant = |g: &std::path::PathBuf| {
976+
let abs = g.canonicalize();
977+
secret_ref.starts_with(abs.as_deref().unwrap_or(g.as_path()))
978+
};
979+
let grant = grants.into_iter().find(|g| covered_by_grant(g)).cloned()?;
980+
Some(Exposure { grant, deny_target })
926981
}
927982

928983
#[cfg(test)]
@@ -937,23 +992,61 @@ mod tests {
937992
let secret = dir.join("key.txt");
938993
std::fs::write(&secret, "s").unwrap();
939994
let grants = vec![dir.clone()];
995+
let found = |denies: &[PathBuf]| {
996+
exposing_grant(&secret, grants.iter(), denies, None).map(|e| e.grant)
997+
};
940998

941999
// Inside a read grant: the exposing grant is reported.
942-
assert_eq!(exposing_grant(&secret, grants.iter(), &[]), Some(dir.clone()));
1000+
assert_eq!(found(&[]), Some(dir.clone()));
9431001
// An fs-deny on the file itself, or on a covering directory, closes the
9441002
// hole: following the warning's own advice must silence it.
945-
assert_eq!(exposing_grant(&secret, grants.iter(), std::slice::from_ref(&secret)), None);
946-
assert_eq!(exposing_grant(&secret, grants.iter(), std::slice::from_ref(&dir)), None);
1003+
assert_eq!(found(std::slice::from_ref(&secret)), None);
1004+
assert_eq!(found(std::slice::from_ref(&dir)), None);
9471005
// A deny elsewhere does not suppress the warning.
948-
assert_eq!(
949-
exposing_grant(&secret, grants.iter(), &[PathBuf::from("/nonexistent-deny")]),
950-
Some(dir.clone())
951-
);
1006+
assert_eq!(found(&[PathBuf::from("/nonexistent-deny")]), Some(dir.clone()));
9521007
// Outside every grant: nothing to report.
9531008
let other = vec![PathBuf::from("/nonexistent-grant")];
954-
assert_eq!(exposing_grant(&secret, other.iter(), &[]), None);
1009+
assert_eq!(exposing_grant(&secret, other.iter(), &[], None).map(|e| e.grant), None);
9551010

9561011
let _ = std::fs::remove_file(&secret);
9571012
let _ = std::fs::remove_dir(&dir);
9581013
}
1014+
1015+
#[test]
1016+
fn exposing_grant_chroot_deny_uses_virtual_namespace() {
1017+
// Jail root with the secret at <root>/etc/token; the child sees it at
1018+
// the in-jail path /etc/token, which is also what --fs-deny matches.
1019+
let root = std::env::temp_dir().join(format!("sandlock-jail-{}", std::process::id()));
1020+
let etc = root.join("etc");
1021+
std::fs::create_dir_all(&etc).unwrap();
1022+
let secret = etc.join("token");
1023+
std::fs::write(&secret, "s").unwrap();
1024+
let grants = vec![root.clone()];
1025+
let chroot = Some(root.as_path());
1026+
let virtual_path = PathBuf::from("/etc/token");
1027+
1028+
// The whole jail is visible: the chroot root grant is reported, and the
1029+
// advised deny path is the in-jail path, not the host path.
1030+
let exposure = exposing_grant(&secret, grants.iter(), &[], chroot).unwrap();
1031+
assert_eq!(exposure.grant, root);
1032+
assert_eq!(exposure.deny_target, virtual_path);
1033+
1034+
// Denying the in-jail path (what the notif layer actually matches, and
1035+
// what the warning now advises) suppresses it.
1036+
assert!(exposing_grant(&secret, grants.iter(), &[virtual_path.clone()], chroot).is_none());
1037+
// Denying a covering in-jail directory also suppresses.
1038+
assert!(
1039+
exposing_grant(&secret, grants.iter(), &[PathBuf::from("/etc")], chroot).is_none()
1040+
);
1041+
// The old advice (the host path) does NOT suppress: at runtime the child
1042+
// opens /etc/token, so a /jail/etc/token deny never fires. This is the
1043+
// regression the fix closes.
1044+
assert!(
1045+
exposing_grant(&secret, grants.iter(), std::slice::from_ref(&secret), chroot).is_some()
1046+
);
1047+
1048+
let _ = std::fs::remove_file(&secret);
1049+
let _ = std::fs::remove_dir(&etc);
1050+
let _ = std::fs::remove_dir(&root);
1051+
}
9591052
}

0 commit comments

Comments
 (0)