fix(rootfs): confine layer names to the cache directory

A layer name lands verbatim in file paths: layers/<name> for the
pointer, <entry>.<key>.tar.gz for keyed entries. --layer ../x
escaped the cache directory on write and read alike. Names are now
restricted to [A-Za-z0-9._-] without a leading dot, enforced at
every surface that accepts one (write, read, extract, and the
prepare_provisioned layer key).
This commit is contained in:
vhaudiquet committed 2026-10-29 23:58:07 +01:00
1 parent dfeb282a26
commit f9250accc0
1 file changed
+46
+46
View File
@@ -46,6 +46,27 @@ use crate::registry::fetch_oci_digest;
/// Sidecar suffix marking a cache entry that holds a provisioned rootfs. /// Sidecar suffix marking a cache entry that holds a provisioned rootfs.
const PROVISIONED_SUFFIX: &str = "provisioned"; const PROVISIONED_SUFFIX: &str = "provisioned";
/// A layer name is a file name under the cache's `layers/` directory
/// and a component of entry file names: keep it to a safe charset so a
/// name like `../x` or `a/b` cannot climb out of the cache directory.
fn validate_layer_name(name: &str) -> Result<()> {
let ok = !name.is_empty()
&& name != "."
&& name != ".."
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
&& !name.starts_with('.');
if ok {
Ok(())
} else {
Err(Error::Other(anyhow!(
"invalid layer name {name:?}: use letters, digits, '_' or '-', \
not starting with '.'"
)))
}
}
/// Sidecar path for a cache entry: `foo.tar.gz` → `foo.tar.gz.<suffix>`. /// Sidecar path for a cache entry: `foo.tar.gz` → `foo.tar.gz.<suffix>`.
fn sidecar(path: &Path, suffix: &str) -> PathBuf { fn sidecar(path: &Path, suffix: &str) -> PathBuf {
let mut s = path.as_os_str().to_owned(); let mut s = path.as_os_str().to_owned();
@@ -104,6 +125,7 @@ impl RootfsCache {
/// named error, not a download — layers are provisioned, never /// named error, not a download — layers are provisioned, never
/// fetched. /// fetched.
pub fn extract_layer(&self, name: &str, progress: &dyn Progress) -> Result<PreparedRootfs> { pub fn extract_layer(&self, name: &str, progress: &dyn Progress) -> Result<PreparedRootfs> {
validate_layer_name(name)?;
let path = self.layer_entry(name)?.ok_or_else(|| { let path = self.layer_entry(name)?.ok_or_else(|| {
Error::Other(anyhow::anyhow!( Error::Other(anyhow::anyhow!(
"layer {name} is not provisioned: run \ "layer {name} is not provisioned: run \
@@ -131,6 +153,7 @@ impl RootfsCache {
/// `run` should not have to re-derive the key from the spec, only /// `run` should not have to re-derive the key from the spec, only
/// agree with the provisioning call on what the layer is called. /// agree with the provisioning call on what the layer is called.
pub fn write_layer_pointer(&self, name: &str, cache_filename: &str) -> Result<()> { pub fn write_layer_pointer(&self, name: &str, cache_filename: &str) -> Result<()> {
validate_layer_name(name)?;
let dir = self.dir.join("layers"); let dir = self.dir.join("layers");
std::fs::create_dir_all(&dir) std::fs::create_dir_all(&dir)
.with_context(|| format!("Failed to create the layer directory {}", dir.display()))?; .with_context(|| format!("Failed to create the layer directory {}", dir.display()))?;
@@ -150,6 +173,7 @@ impl RootfsCache {
/// names can be gone (a pruned cache, a base bump) while the /// names can be gone (a pruned cache, a base bump) while the
/// pointer survives. /// pointer survives.
pub fn layer_entry(&self, name: &str) -> Result<Option<PathBuf>> { pub fn layer_entry(&self, name: &str) -> Result<Option<PathBuf>> {
validate_layer_name(name)?;
let pointer = self.dir.join("layers").join(name); let pointer = self.dir.join("layers").join(name);
let filename = match std::fs::read_to_string(&pointer) { let filename = match std::fs::read_to_string(&pointer) {
Ok(filename) => filename, Ok(filename) => filename,
@@ -275,6 +299,9 @@ impl RootfsCache {
{ {
let arch = req.resolved_arch(); let arch = req.resolved_arch();
let source = resolve_source(&req.image, &arch)?; let source = resolve_source(&req.image, &arch)?;
if let Some(key) = &req.layer_key {
validate_layer_name(key)?;
}
let cache_path = self.entry_path(&source, &arch, req.layer_key.as_deref()); let cache_path = self.entry_path(&source, &arch, req.layer_key.as_deref());
let marker = sidecar(&cache_path, PROVISIONED_SUFFIX); let marker = sidecar(&cache_path, PROVISIONED_SUFFIX);
@@ -809,6 +836,25 @@ mod tests {
assert_ne!(keyed, other, "two keys are two entries"); assert_ne!(keyed, other, "two keys are two entries");
} }
#[test]
fn layer_names_cannot_escape_the_cache_directory() {
let dir = tempfile::tempdir().unwrap();
let cache = RootfsCache::new(dir.path().to_path_buf());
for bad in ["../evil", "a/b", "", ".", "..", ".hidden", "a\0b"] {
assert!(
cache.write_layer_pointer(bad, "x.tar.gz").is_err(),
"{bad:?} must be rejected"
);
assert!(cache.layer_entry(bad).is_err(), "{bad:?} must be rejected");
}
for good in ["base", "dev-tools", "ci_2026", "Layer1"] {
assert!(
validate_layer_name(good).is_ok(),
"{good:?} must be accepted"
);
}
}
#[test] #[test]
fn a_layer_pointer_resolves_only_while_the_entry_exists() { fn a_layer_pointer_resolves_only_while_the_entry_exists() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");