fix(vault): canonicalize + allowed-root containment on provision target (#92) #119

Merged
clawdie merged 1 commit from fix/vault-provision-containment into main 2026-06-21 06:30:30 +02:00

View file

@ -53,6 +53,9 @@ pub enum VaultError {
#[error("cannot write to target: {0}")]
TargetNotWritable(PathBuf),
#[error("target '{target}' does not resolve under the allowed jail root '{base}'")]
TargetEscapesRoot { target: PathBuf, base: PathBuf },
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
@ -77,6 +80,13 @@ pub async fn provision(
// Verify bw is available
which("bw").ok_or(VaultError::BwNotFound)?;
// Containment: the target must resolve (symlinks + `..` collapsed) to a path
// strictly under the allowed jail-root base. This runs BEFORE create_dir_all
// so a traversal/symlink target can never have a directory — let alone an
// .env — created outside the jails tree. Fail-closed (returns Err; the
// spawn hook treats provision errors as fail-soft "no .env written").
assert_contained(target_dir)?;
// Verify target is writable
std::fs::create_dir_all(target_dir)?;
let test = target_dir.join(".vault-write-test");
@ -102,6 +112,40 @@ pub struct ProvisionResult {
pub path: PathBuf,
}
// ── Containment ───────────────────────────────────────
/// Allowed jail-root base. Tenant `.env` targets must resolve to a path strictly
/// under this. Defaults to the FreeBSD/Bastille convention; override with
/// `COLIBRI_JAIL_ROOT_BASE` (e.g. the container volume root on Linux/Docker).
fn jail_root_base() -> PathBuf {
std::env::var_os("COLIBRI_JAIL_ROOT_BASE")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/usr/local/bastille/jails"))
}
/// Canonicalize `target` and assert it lives strictly under `base` (also
/// canonicalized), so `..` and symlinks cannot escape. Returns the resolved
/// target on success. Errors if either path cannot be canonicalized (e.g. the
/// target does not exist — provisioning targets an already-spawned jail).
fn assert_contained_under(base: &Path, target: &Path) -> Result<PathBuf, VaultError> {
let escapes = || VaultError::TargetEscapesRoot {
target: target.to_path_buf(),
base: base.to_path_buf(),
};
let base_real = std::fs::canonicalize(base).map_err(|_| escapes())?;
let target_real = std::fs::canonicalize(target).map_err(|_| escapes())?;
if target_real != base_real && target_real.starts_with(&base_real) {
Ok(target_real)
} else {
Err(escapes())
}
}
/// Containment guard against the configured [`jail_root_base`].
fn assert_contained(target: &Path) -> Result<PathBuf, VaultError> {
assert_contained_under(&jail_root_base(), target)
}
// ── Internal helpers ──────────────────────────────────
struct VaultSession {
@ -476,4 +520,57 @@ mod tests {
assert!(validate_key("KEY\nMALICIOUS=1").is_empty());
assert!(validate_key("").is_empty());
}
fn unique_tmp(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let mut p = std::env::temp_dir();
p.push(format!(
"colibri-vault-{tag}-{}-{nanos}",
std::process::id()
));
p
}
#[test]
fn assert_contained_under_enforces_jail_root() {
use std::fs;
let base = unique_tmp("base");
let jail_root = base.join("jailA/root");
fs::create_dir_all(&jail_root).unwrap();
// a real child resolves and is accepted
assert_eq!(
assert_contained_under(&base, &jail_root).unwrap(),
fs::canonicalize(&jail_root).unwrap()
);
// the base itself is not strictly under the base
assert!(assert_contained_under(&base, &base).is_err());
// a nonexistent target cannot be canonicalized -> refused
assert!(assert_contained_under(&base, &base.join("nope")).is_err());
// `..` traversal climbing above the base -> refused
assert!(assert_contained_under(&base, &jail_root.join("../../..")).is_err());
let _ = fs::remove_dir_all(&base);
}
#[cfg(unix)]
#[test]
fn assert_contained_under_rejects_symlink_escape() {
use std::fs;
let base = unique_tmp("symbase");
let outside = unique_tmp("outside");
fs::create_dir_all(&base).unwrap();
fs::create_dir_all(&outside).unwrap();
let link = base.join("evil");
std::os::unix::fs::symlink(&outside, &link).unwrap();
// a symlink under base that resolves outside is refused
assert!(assert_contained_under(&base, &link).is_err());
let _ = fs::remove_dir_all(&base);
let _ = fs::remove_dir_all(&outside);
}
}