{"record":{"id":"38a90907c32e80b0","repo":"zylon-ai/private-gpt","slug":"unsafe-path-traversal","errorCode":"UNSAFE_PATH_TRAVERSAL","errorMessage":"Unsafe file path (path traversal): {path!r}","messagePattern":"Unsafe file path \\(path traversal\\): (.+?)","errorType":"error_code","errorClass":"SkillDomainError","httpStatus":null,"severity":"error","filePath":"private_gpt/server/skills/skills_files.py","lineNumber":118,"sourceCode":"        )\n\n    return list(resolved.values())\n\n\ndef _normalize_path(path: str) -> str:\n    # Normalize backslashes to forward slashes\n    path = path.replace(\"\\\\\", \"/\")\n\n    parsed = PurePosixPath(path)\n\n    if parsed.is_absolute():\n        raise SkillDomainError(\n            SkillErrorCode.UNSAFE_PATH_ABSOLUTE,\n            f\"Unsafe file path (absolute): {path!r}\",\n        )\n\n    if \"..\" in parsed.parts:\n        raise SkillDomainError(\n            SkillErrorCode.UNSAFE_PATH_TRAVERSAL,\n            f\"Unsafe file path (path traversal): {path!r}\",\n        )\n\n    parts = list(parsed.parts)\n    if parts and parts[-1].lower() == \"skill.md\":\n        parts[-1] = \"SKILL.md\"\n    return \"/\".join(parts)\n\n\n_DEFAULT_MIME_TYPE = \"application/octet-stream\"\n\n\ndef _infer_file_meta(field_name: str, content: bytes | None) -> tuple[str, str]:\n    if content is None:\n        return field_name, _DEFAULT_MIME_TYPE\n\n    from private_gpt.utils.mime import is_magic_available","sourceCodeStart":100,"sourceCodeEnd":136,"githubUrl":"https://github.com/zylon-ai/private-gpt/blob/4a030776a31a901ad80b1bf4d7faa2c1a367efbb/private_gpt/server/skills/skills_files.py#L100-L136","documentation":"SkillDomainError with code UNSAFE_PATH_TRAVERSAL: _normalize_path rejects any path whose PurePosixPath parts contain '..' after backslash normalization. This blocks classic zip-slip attacks ('../../etc/cron.d/x') where extraction would write outside the destination directory. The check runs on every entry from a zip or upload path.","triggerScenarios":"A zip entry such as '../../outside.txt', 'assets/../../escape.md', or Windows-style '..\\\\..\\\\x' (normalized to '../../x'). Any single '..' component anywhere in the path triggers rejection.","commonSituations":"Malicious or untrusted uploaded archives; archives created with flawed relative-path math (os.path.join mistakes producing '..'); symlink-heavy source trees zipped naively; penetration-test payloads.","solutions":["Regenerate the archive from a clean checkout ensuring no '..' components: `zip -r skill.zip .` from the intended root.","Fix the packaging code: compute arcname relative to the root (e.g. `path.relative_to(root)`), never os.path.relpath that can escape upward.","Pre-validate untrusted zips server-side/client-side: `any('..' in PurePosixPath(n).parts for n in zf.namelist())` and reject before upload.","Treat hits on trusted archives as a sign of corrupted tooling — inspect with `unzip -l`."],"exampleFix":"# before\narc = os.path.relpath(file_path, output_dir)  # can yield '../../x' for files outside output_dir\n# after\narc = str(Path(file_path).relative_to(skill_root))  # always inside the root","handlingStrategy":"validation","validationCode":"from pathlib import PurePosixPath\n\ndef entries_are_safe(names: list[str]) -> bool:\n    return all('..' not in PurePosixPath(n.replace('\\\\', '/')).parts for n in names)","typeGuard":"const isSafeEntry = (entry: string): boolean =>\n  !entry.replace(/\\\\/g, '/').split('/').includes('..');","tryCatchPattern":null,"preventionTips":["Compute archive paths with Path.relative_to(root), never os.path.relpath across roots.","Add a CI lint that rejects archives containing '..' members.","Treat any '..' in an uploaded archive as hostile input."],"tags":["security","zip-slip","path-traversal","skills","upload"],"backgroundTag":null,"analyzedSha":"4a030776a31a901ad80b1bf4d7faa2c1a367efbb","analyzedAt":"2026-08-15T03:51:26.951Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}