{"record":{"id":"1d3cb44a3147b706","repo":"Hmbown/CodeWhale","slug":"external-credential-path-escapes-its-absolute-root","errorCode":null,"errorMessage":"external credential path escapes its absolute root: {}","messagePattern":"external credential path escapes its absolute root: (.+?)","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/config/src/external_credentials.rs","lineNumber":129,"sourceCode":"    } else {\n        std::env::current_dir()\n            .map_err(|err| anyhow::anyhow!(\"resolving external credential path: {err}\"))?\n            .join(path)\n    };\n\n    // Normalize only lexical `.` / `..` components. Canonicalization would\n    // inspect a credential path before consent exists and would also silently\n    // bless a symlink target. The secure reader rejects symlink/reparse-point\n    // components when the granted capability is actually consumed.\n    let mut normalized = PathBuf::new();\n    for component in absolute.components() {\n        match component {\n            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),\n            Component::RootDir => normalized.push(component.as_os_str()),\n            Component::CurDir => {}\n            Component::ParentDir => {\n                if !normalized.pop() {\n                    bail!(\n                        \"external credential path escapes its absolute root: {}\",\n                        quote_os_path(&absolute)\n                    );\n                }\n            }\n            Component::Normal(part) => normalized.push(part),\n        }\n    }\n    if !normalized.is_absolute() {\n        bail!(\n            \"external credential path must resolve to an absolute path: {}\",\n            quote_os_path(&normalized)\n        );\n    }\n    Ok(normalized)\n}\n\n/// The side-effect envelope Codewhale may use for an external credential.","sourceCodeStart":111,"sourceCodeEnd":147,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/0c42157ee52f9d55af2b506d71b46249910f77d3/crates/config/src/external_credentials.rs#L111-L147","documentation":"External-credential consent paths are normalized lexically (only '.'/'..' components, no canonicalize, so no symlink blessing and no I/O before consent) inside resolve_external_credential_path(). If a ParentDir ('..') component pops past the front of the accumulated absolute path, the path would escape its absolute root and normalization bails rather than producing a path outside the granted scope.","triggerScenarios":"A consented external credential path whose lexical normalization walks above the root: e.g. '/keys/../../etc/passwd' — after popping '/keys' then '/', another '..' finds nothing left to pop and the bail fires.","commonSituations":"Hand-crafted or copy-pasted consent paths with too many '..' segments; dotfile-style relative-ish paths pasted into a field that expects an absolute normalized path; probing/misconfiguration where the credential path was meant to point inside a directory tree.","solutions":["Fix the consented path so it stays inside its root after removing '.'/'..' components","Re-create the consent with the real absolute file location of the external credential"],"exampleFix":"# before\n/path/to/keys/../../other/creds.env   # escapes during normalization\n\n# after\n/other/creds.env   # or the direct absolute path with no '..' segments","handlingStrategy":"validation","validationCode":"// Reject traversal before creating/using a consent:\nfn lexically_within_root(path: &std::path::Path) -> bool {\n    let mut norm = std::path::PathBuf::new();\n    for c in path.components() {\n        match c {\n            std::path::Component::ParentDir => if !norm.pop() { return false },\n            std::path::Component::CurDir => {}\n            other => norm.push(other.as_os_str()),\n        }\n    }\n    norm.is_absolute()\n}","typeGuard":"fn is_traversal_free(absolute: &Path) -> bool {\n    let mut depth = 0usize;\n    for c in absolute.components() {\n        match c {\n            Component::Normal(_) => depth += 1,\n            Component::ParentDir => { if depth == 0 { return false } depth -= 1 }\n            _ => {}\n        }\n    }\n    true\n}","tryCatchPattern":"match resolve_external_credential_path(&p) {\n    Ok(normalized) => { /* use normalized */ }\n    Err(e) if e.to_string().contains(\"escapes its absolute root\") => {\n        // input error in consent data: reject the consent, never coerce\n        reject_consent_input(e)\n    }\n    Err(e) => return Err(e),\n}","preventionTips":["Always store consent paths fully expanded with no '..' components","Build credential paths via Path::join on absolute bases instead of string concatenation","Treat traversal-shaped paths as hostile input, not as fixable at read time"],"tags":["security","path-validation","credentials","consent"],"backgroundTag":"path-traversal-rejected","analyzedSha":"0c42157ee52f9d55af2b506d71b46249910f77d3","analyzedAt":"2026-08-20T21:50:45.477Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}