{"record":{"id":"b2a9f0f24eab2ef6","repo":"farion1231/cc-switch","slug":"invalid-skill-directory-possible-path-traversal","errorCode":null,"errorMessage":"Invalid skill directory (possible path traversal): {directory:?}","messagePattern":"Invalid skill directory \\(possible path traversal\\): (.+?)","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"src-tauri/src/services/skill.rs","lineNumber":2835,"sourceCode":"            }\n            _ => None,\n        }\n    }\n\n    /// 校验来自 DB 行 / 备份 meta.json 等外部来源的 directory 字段。\n    ///\n    /// 存储值按构造本应是单段安装名（见 sanitize_install_name），但有两个入口\n    /// 会绕过安装期校验：同步导入的远端快照直接灌库（raw SQL），以及手工放置 /\n    /// 不可信备份里的 meta.json。任何把它 join 进文件系统路径的使用点（尤其是\n    /// remove_dir_all 这类删除操作）必须先过这道校验，拒绝路径穿越。\n    ///\n    /// 只校验、不归一化：`sanitize_install_name` 会 `trim()`，若拿它的返回值替换\n    /// 原值，磁盘上真实带空格的目录名就再也 join 不中。所以这里要求归一化结果与\n    /// 原值逐字相同，否则一律视为非法。\n    fn require_valid_directory(directory: &str) -> Result<String> {\n        match Self::sanitize_install_name(directory) {\n            Some(normalized) if normalized == directory => Ok(normalized),\n            _ => Err(anyhow!(\n                \"Invalid skill directory (possible path traversal): {directory:?}\"\n            )),\n        }\n    }\n\n    /// GitHub 账号名（user / org login）。\n    ///\n    /// 只放行 ASCII 字母数字与 `-`。这比 GitHub 自身的规则更严，但该字段会被拼进\n    /// 下载 URL，任何 `/`、`.`、`%`、`\\` 都可能改写请求落点（见 validate_repo_ref）。\n    fn is_valid_github_owner(owner: &str) -> bool {\n        !owner.is_empty()\n            && owner.len() <= 39\n            && owner.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')\n    }\n\n    /// GitHub 仓库名。允许 `.` `-` `_`，但整体不能是 `.` 或 `..`。\n    fn is_valid_github_repo_name(name: &str) -> bool {\n        !name.is_empty()","sourceCodeStart":2817,"sourceCodeEnd":2853,"githubUrl":"https://github.com/farion1231/cc-switch/blob/a2e22f330273a5b6ffa87cb8b82b624601bac562/src-tauri/src/services/skill.rs#L2817-L2853","documentation":"Security guard in require_valid_directory: a stored skill `directory` value must be a canonical single path segment. It re-runs sanitize_install_name and requires the normalized result to equal the raw value byte-for-byte; any difference (leading/trailing whitespace, separators, dot-prefix, empty) is rejected as a possible path traversal. The doc comment explains two entrances bypass install-time validation — remote snapshots written via raw SQL and untrusted meta.json — so every join-into-filesystem-path site (especially remove_dir_all) must call this first.","triggerScenarios":"A DB row or meta.json carrying a directory like \" my-skill\" (whitespace, normalization differs), \"../escape\", \"a/b\", \"a\\\\b\", \".hidden\", or \"..\". Typically introduced by a polluted WebDAV-synced remote snapshot or a hand-crafted backup, then tripped when sync, delete, or uninstall joins the value into a path.","commonSituations":"Multi-device sync importing a snapshot written by an older/buggy version; restoring a backup edited by hand; tampered skill packages. The error is a defense firing, not a bug in the caller.","solutions":["Audit the database: SELECT directory FROM installed_skills and compare each value against sanitize_install_name output","Reinstall the affected skill so the row is rewritten through the normal install path","Delete the offending row/directory if the skill is unknown; do not 'fix' it by renaming with whitespace kept on disk (the guard intentionally refuses normalization because the real on-disk name may contain spaces)"],"exampleFix":"// before — trusting a DB row before a filesystem delete\nlet path = ssot_dir.join(&row.directory);\nfs::remove_dir_all(&path)?;\n\n// after — validate before any join\nlet directory = SkillService::require_valid_directory(&row.directory)?;\nlet path = ssot_dir.join(&directory);\nfs::remove_dir_all(&path)?;","handlingStrategy":"type-guard","validationCode":"// Rust — audit DB values before any filesystem operation on them\nfor dir in db.get_all_installed_skills()?.keys() {\n    if SkillService::sanitize_install_name(dir).map_or(true, |n| n != dir) {\n        log::warn!(\"polluted directory value in DB: {dir:?}\");\n    }\n}","typeGuard":"// Rust predicate mirroring the guard, for callers that hold raw values\nfn is_valid_skill_directory(raw: &str) -> bool {\n    let t = raw.trim();\n    !t.is_empty()\n        && !t.contains('/')\n        && !t.contains('\\\\')\n        && !t.starts_with('.')\n        && t != \".\" && t != \"..\"\n        && t == raw // no normalization delta allowed\n}","tryCatchPattern":"match SkillService::require_valid_directory(&row.directory) {\n    Ok(dir) => { /* safe to join into paths */ }\n    Err(_) => { /* drop the row / quarantine the skill; never fall back to raw value */ }\n}","preventionTips":["Never write directory values into the DB via raw SQL; only through the install path that runs sanitize_install_name","Validate directory strings the moment they enter from remote snapshots or meta.json files","On error, do not 'repair' by using the sanitized value as the new name — on-disk names may legitimately contain spaces"],"tags":["security","path-traversal","validation","database","skill"],"backgroundTag":null,"analyzedSha":"a2e22f330273a5b6ffa87cb8b82b624601bac562","analyzedAt":"2026-08-16T03:46:07.889Z","schemaVersion":2},"datasetVersion":"2026-08-16T08:17:34.114Z"}