{"record":{"id":"4a77dd1587620800","repo":"Hmbown/CodeWhale","slug":"bundle-path-candidate-escapes-the-config-directory-via-a","errorCode":null,"errorMessage":"bundle path {candidate:?} escapes the config directory via a symlink; refused","messagePattern":"bundle path (.+?) escapes the config directory via a symlink; refused","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/cli/src/config_bundles.rs","lineNumber":741,"sourceCode":"        .canonicalize()\n        .with_context(|| format!(\"config directory {} is unavailable\", base_dir.display()))?;\n    let joined = base_dir.join(candidate_path);\n    // Walk the joined path's ancestors from the deepest existing component up:\n    // every existing component must canonicalize inside the base, so a symlink\n    // pointing outside the config directory is refused even when the final\n    // target does not exist yet.\n    let deepest_existing = joined\n        .ancestors()\n        .find(|ancestor| ancestor.symlink_metadata().is_ok())\n        .context(\"bundle path has no existing ancestor inside the config directory\")?;\n    let resolved = deepest_existing.canonicalize().with_context(|| {\n        format!(\n            \"could not resolve bundle path component {}\",\n            deepest_existing.display()\n        )\n    })?;\n    if !resolved.starts_with(&canonical_base) {\n        bail!(\"bundle path {candidate:?} escapes the config directory via a symlink; refused\");\n    }\n    Ok(joined)\n}\n\n// ---------------------------------------------------------------------------\n// Remote fetch\n// ---------------------------------------------------------------------------\n\n/// Fetch a bundle over HTTPS (or plain http on loopback only) with a hard\n/// size cap, a timeout, and bounded redirects. Mirrors the skill installer's\n/// fetch bounds.\npub fn fetch_bundle(url: &str) -> Result<Vec<u8>> {\n    let mut current_url = reqwest::Url::parse(url).map_err(|_| anyhow!(\"invalid bundle URL\"))?;\n    validate_bundle_url(&current_url)?;\n    let initial_scheme = current_url.scheme().to_string();\n\n    let client = codewhale_release::platform_blocking_http_client_builder()\n        .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))","sourceCodeStart":723,"sourceCodeEnd":759,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/73e0f67d83c59909b571efdfc88c4bc28c309cb1/crates/cli/src/config_bundles.rs#L723-L759","documentation":"The resolved path of a bundle entry, after canonicalizing the deepest existing component, does not lie under the canonicalized config directory — meaning a symlink inside the config directory points outside it. `resolve_bounded_path` rejects this to stop symlink-based escapes from the config directory.","triggerScenarios":"Importing a bundle whose candidate path traverses a symlink inside the config directory that targets a location outside it; calling `resolve_bounded_path` where `resolved.starts_with(canonical_base)` fails.","commonSituations":"A user symlinked a config subdirectory to an external location (e.g. dotfiles managed with symlinks) and then imported a bundle touching it; an attacker-planted symlink in a shared config directory.","solutions":["Remove or replace the escaping symlink inside the config directory so the target resolves within it.","Point the bundle path at a real file inside the config directory instead of through the symlink.","If the external location is intentional, copy the content into the config directory rather than symlinking it."],"exampleFix":"// before (inside ~/.codewhale)\nsettings -> /mnt/external/settings\n// after\ncp /mnt/external/settings ~/.codewhale/settings && rm settings","handlingStrategy":"validation","validationCode":"let canonical_base = base_dir.canonicalize()?;\nlet resolved = base_dir.join(candidate);\nlet resolved_existing = resolved.ancestors().find(|p| p.exists()).ok_or(\"missing\")?.canonicalize()?;\nif !resolved_existing.starts_with(&canonical_base) {\n    return Err(\"candidate resolves outside config directory\".into());\n}","typeGuard":"fn stays_inside_base(base: &std::path::Path, candidate: &str) -> bool {\n    (|| {\n        let cb = base.canonicalize().ok()?;\n        let p = base.join(candidate);\n        let deepest = p.ancestors().find(|a| a.exists())?;\n        deepest.canonicalize().ok()?.starts_with(&cb).then_some(true)\n    })().unwrap_or(false)\n}","tryCatchPattern":"match resolve_bounded_path(&base_dir, candidate) {\n    Ok(path) => apply(path),\n    Err(e) if e.to_string().contains(\"symlink\") => log::warn!(\"bundle path escapes via symlink: {candidate:?}\"),\n    Err(e) => return Err(e),\n}","preventionTips":["Avoid symlinking config subdirectories to locations outside the config directory.","Copy external content into the config directory instead of symlinking it.","Audit shared config directories for unexpected symlinks before importing bundles."],"tags":["symlink","path-validation","security"],"backgroundTag":"path-traversal-blocked","analyzedSha":"73e0f67d83c59909b571efdfc88c4bc28c309cb1","analyzedAt":"2026-09-22T01:30:00.501Z","contentChangedAt":"2026-09-22T01:30:00.501Z","schemaVersion":2},"datasetVersion":"2026-09-22T06:17:15.046Z"}