fix(vault): canonicalize + allowed-root containment on provision target (#92) #119
1 changed files with 97 additions and 0 deletions
|
|
@ -53,6 +53,9 @@ pub enum VaultError {
|
||||||
#[error("cannot write to target: {0}")]
|
#[error("cannot write to target: {0}")]
|
||||||
TargetNotWritable(PathBuf),
|
TargetNotWritable(PathBuf),
|
||||||
|
|
||||||
|
#[error("target '{target}' does not resolve under the allowed jail root '{base}'")]
|
||||||
|
TargetEscapesRoot { target: PathBuf, base: PathBuf },
|
||||||
|
|
||||||
#[error("I/O error: {0}")]
|
#[error("I/O error: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
|
@ -77,6 +80,13 @@ pub async fn provision(
|
||||||
// Verify bw is available
|
// Verify bw is available
|
||||||
which("bw").ok_or(VaultError::BwNotFound)?;
|
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
|
// Verify target is writable
|
||||||
std::fs::create_dir_all(target_dir)?;
|
std::fs::create_dir_all(target_dir)?;
|
||||||
let test = target_dir.join(".vault-write-test");
|
let test = target_dir.join(".vault-write-test");
|
||||||
|
|
@ -102,6 +112,40 @@ pub struct ProvisionResult {
|
||||||
pub path: PathBuf,
|
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 ──────────────────────────────────
|
// ── Internal helpers ──────────────────────────────────
|
||||||
|
|
||||||
struct VaultSession {
|
struct VaultSession {
|
||||||
|
|
@ -476,4 +520,57 @@ mod tests {
|
||||||
assert!(validate_key("KEY\nMALICIOUS=1").is_empty());
|
assert!(validate_key("KEY\nMALICIOUS=1").is_empty());
|
||||||
assert!(validate_key("").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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue