{"record":{"id":"6b8fef3ea2823426","repo":"jdx/mise","slug":"remote-cache-path-escapes-its-output-root","errorCode":null,"errorMessage":"remote cache path escapes its output root","messagePattern":"remote cache path escapes its output root","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/task/task_cache_store.rs","lineNumber":511,"sourceCode":"    async fn remove(&self, _key: &str) -> Result<()> {\n        // Ordinary cache writers intentionally have no remote-delete authority.\n        Ok(())\n    }\n\n    fn touch(&self, _key: &str) {}\n}\n\nfn validate_cache_path(path: &Path) -> Result<()> {\n    if path.as_os_str().is_empty() || path.is_absolute() {\n        bail!(\"remote cache path must be relative\");\n    }\n    if path.components().any(|component| {\n        matches!(\n            component,\n            Component::ParentDir | Component::RootDir | Component::Prefix(_)\n        )\n    }) {\n        bail!(\"remote cache path escapes its output root\");\n    }\n    Ok(())\n}\n\nfn cache_name(path: &Path) -> Result<String> {\n    let name = path\n        .file_name()\n        .and_then(|name| name.to_str())\n        .ok_or_else(|| eyre!(\"remote cache paths must be valid UTF-8\"))?;\n    if name.is_empty() || name == \".\" || name == \"..\" || name.contains(['/', '\\0']) {\n        bail!(\"invalid remote cache path component\");\n    }\n    Ok(name.to_string())\n}\n\nfn validate_cache_name(name: &str) -> Result<()> {\n    let path = Path::new(name);\n    if name.is_empty()","sourceCodeStart":493,"sourceCodeEnd":529,"githubUrl":"https://github.com/jdx/mise/blob/9dcfcaa0dc8747a2577d3270b69bb9d8313b2807/src/task/task_cache_store.rs#L493-L529","documentation":"Thrown by validate_cache_path when mise restores a task output from the HTTP remote cache. Every path that comes back from the remote store (the manifest's roots array and each joined child path in the directory tree) must be a plain relative path: absolute paths and any ParentDir, RootDir, or Windows drive-Prefix component are rejected. It is a path-traversal guard so a corrupt or hostile cache server cannot make mise write outside the restore directory.","triggerScenarios":"HttpTaskCacheStore::get() downloads a manifest whose roots contain e.g. \"/out\" or \"a/../../etc\"; or materialize_remote_tree() joins a remote directory entry name with its parent and the result contains a ParentDir/RootDir/Prefix component. The bail happens before any file is written.","commonSituations":"Shared CAS/HTTP cache written by non-mise tooling or hand-edited manifests; a proxy or script rewriting JSON bodies; version skew where another mise release serialized roots differently; deliberate tampering tests against the cache server.","solutions":["Purge the affected entry (action result + blobs) from the remote cache and re-run the task so a trusted mise re-uploads it","Verify every writer to the shared cache runs the same pinned mise version (CI included)","If you operate the cache server, validate directory protos and manifests for relative, single-tree paths at upload time","Reproduce with MISE_DEBUG=1 to capture the offending digest and inspect the blob it served"],"exampleFix":"// remote manifest roots (downloaded blob)\n// before\n\"roots\": [\"/home/ci/build/out\"]\n// after\n\"roots\": [\"out\"]","handlingStrategy":"validation","validationCode":"use std::path::{Component, Path};\n\nfn is_safe_cache_path(path: &Path) -> bool {\n    !path.as_os_str().is_empty()\n        && !path.is_absolute()\n        && !path.components().any(|c| matches!(\n            c,\n            Component::ParentDir | Component::RootDir | Component::Prefix(_)\n        ))\n}\n\n// before trusting a downloaded manifest:\nfor root in &manifest.roots {\n    assert!(is_safe_cache_path(Path::new(root)), \"corrupt cache entry\");\n}","typeGuard":"fn is_safe_cache_path(path: &std::path::Path) -> bool {\n    !path.as_os_str().is_empty()\n        && !path.is_absolute()\n        && !path.components().any(|c| matches!(\n            c,\n            std::path::Component::ParentDir\n                | std::path::Component::RootDir\n                | std::path::Component::Prefix(_)\n        ))\n}","tryCatchPattern":"match restore_result {\n    Ok(entry) => entry,\n    Err(err) if err.to_string().contains(\"escapes its output root\") => {\n        warn!(\"corrupt remote cache entry; purging and rebuilding\");\n        purge_remote_entry(&key).await?;\n        run_uncached(&task).await?\n    }\n    Err(err) => return Err(err),\n}","preventionTips":["Only let a single pinned mise version write to a shared remote cache","Reject non-relative root paths at the cache server on upload","Treat any traversal-family restore error as cache corruption: purge and rebuild, never ignore","Onboard shared caches in CI with MISE_DEBUG=1 so bad blobs are identified early"],"tags":["mise","remote-cache","task-cache","path-traversal","security"],"backgroundTag":"path-traversal","analyzedSha":"9dcfcaa0dc8747a2577d3270b69bb9d8313b2807","analyzedAt":"2026-08-17T14:28:50.624Z","schemaVersion":2},"datasetVersion":"2026-08-21T13:17:26.733Z"}