zylon-ai/private-gpt · error · SkillDomainError
UNSAFE_PATH_TRAVERSAL
UNSAFE_PATH_TRAVERSAL
Error message
Unsafe file path (path traversal): {path!r} What it means
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.
Source
Thrown at private_gpt/server/skills/skills_files.py:118
)
return list(resolved.values())
def _normalize_path(path: str) -> str:
# Normalize backslashes to forward slashes
path = path.replace("\\", "/")
parsed = PurePosixPath(path)
if parsed.is_absolute():
raise SkillDomainError(
SkillErrorCode.UNSAFE_PATH_ABSOLUTE,
f"Unsafe file path (absolute): {path!r}",
)
if ".." in parsed.parts:
raise SkillDomainError(
SkillErrorCode.UNSAFE_PATH_TRAVERSAL,
f"Unsafe file path (path traversal): {path!r}",
)
parts = list(parsed.parts)
if parts and parts[-1].lower() == "skill.md":
parts[-1] = "SKILL.md"
return "/".join(parts)
_DEFAULT_MIME_TYPE = "application/octet-stream"
def _infer_file_meta(field_name: str, content: bytes | None) -> tuple[str, str]:
if content is None:
return field_name, _DEFAULT_MIME_TYPE
from private_gpt.utils.mime import is_magic_availableView on GitHub (pinned to 4a030776a3)
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`.
Example fix
# before arc = os.path.relpath(file_path, output_dir) # can yield '../../x' for files outside output_dir # after arc = str(Path(file_path).relative_to(skill_root)) # always inside the root
Defensive patterns
Strategy: validation
Validate before calling
from pathlib import PurePosixPath
def entries_are_safe(names: list[str]) -> bool:
return all('..' not in PurePosixPath(n.replace('\\', '/')).parts for n in names) Type guard
const isSafeEntry = (entry: string): boolean =>
!entry.replace(/\\/g, '/').split('/').includes('..'); Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- UNSAFE_PATH_ABSOLUTE
- 'allOf' must be an array of schemas
- Invalid system specification (dict): {system}
- BUNDLE_TOO_LARGE
- MISSING_SKILL_MD
AI-assisted analysis of zylon-ai/private-gpt@4a030776a3 (2026-08-15).
Data as JSON: /api/errors/38a90907c32e80b0.
Report an issue: GitHub.