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_available

View on GitHub (pinned to 4a030776a3)

Solutions

  1. Regenerate the archive from a clean checkout ensuring no '..' components: `zip -r skill.zip .` from the intended root.
  2. Fix the packaging code: compute arcname relative to the root (e.g. `path.relative_to(root)`), never os.path.relpath that can escape upward.
  3. Pre-validate untrusted zips server-side/client-side: `any('..' in PurePosixPath(n).parts for n in zf.namelist())` and reject before upload.
  4. 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

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


AI-assisted analysis of zylon-ai/private-gpt@4a030776a3 (2026-08-15). Data as JSON: /api/errors/38a90907c32e80b0. Report an issue: GitHub.