-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The validator inspected one field. The kernel follows the other.\n\nWhen `tar.extractall` reaches a `SYMTYPE` member, the underlying syscall is `os.symlink(member.linkname, dest_dir / member.name)`. The first argument is where the link points. The second argument is where the link is created. Both arguments come from attacker-controlled bytes inside the archive. The pre-patch `_safe_extractall` validated the second one.\n\nHere is the pre-patch loop, from `src/praisonai/praisonai/recipe/registry.py:131-178`:\n\n```python\ndef _safe_extractall(tar: tarfile.TarFile, dest_dir: Path) -> None:\n MAX_SIZE = 100 * 1024 * 1024\n MAX_FILES = 1000\n\n total_size = 0\n file_count = 0\n\n dest_resolved = dest_dir.resolve()\n for member in tar.getmembers():\n file_count += 1\n if file_count > MAX_FILES:\n raise RegistryError(f\"Archive contains too many files (>{MAX_FILES})\")\n\n total_size += member.size\n if total_size > MAX_SIZE:\n raise RegistryError(f\"Archive is too large uncompressed (>{MAX_SIZE} bytes)\")\n\n member_path = Path(member.name)\n if member_path.is_absolute():\n raise RegistryError(\n f\"Refusing to extract absolute path in archive: {member.name}\"\n )\n if '..' in member_path.parts:\n raise RegistryError(\n f\"Refusing to extract path traversal in archive: {member.name}\"\n )\n resolved = (dest_resolved / member_path).resolve()\n if not str(resolved).startswith(str(dest_resolved) + os.sep) and resolved != dest_resolved:\n raise RegistryError(\n f\"Refusing to extract path escaping target directory: {member.name}\"\n )\n tar.extractall(dest_dir)\n```\n\nEvery line of the loop reads `member.name` or constructs a path from `member.name`. The identifier `member.linkname` does not appear in the function body. It does not appear in the function's namespace at all.\n\nThe advisory's attack shape uses two archive members. The first is a `SYMTYPE` member with `name = \"escape\"` and `linkname = \"/tmp/PWNED\"`. The validator reads `escape`, confirms it is not absolute, confirms it contains no `..`, computes `(dest_resolved / \"escape\").resolve()`, and confirms the result lives inside `dest_resolved`. The member passes. The second is a regular file with `name = \"escape/payload\"`. The validator computes `(dest_resolved / \"escape/payload\").resolve()` and confirms the result lives inside `dest_resolved`. The member passes.\n\nExtraction begins. Member one is a symlink, so `tar.extractall` issues `os.symlink(\"/tmp/PWNED\", \"dest_dir/escape\")`. The kernel creates a real symlink on the real filesystem pointing at `/tmp/PWNED`. Member two is a regular file, so `tar.extractall` opens `dest_dir/escape/payload` for writing. The kernel resolves the path against the on-disk symlink that member one just created and writes the bytes to `/tmp/PWNED/payload`.\n\nThe write lands outside `dest_dir`. The validator never saw the path that received the write, because the path that received the write did not exist when the validator ran.\n\n## The validator resolved against an empty directory. The extractor wrote against a populated one.\n\n`Path.resolve()` follows symlinks on the actual filesystem. At validate time, the only thing under `dest_resolved` is the freshly created `dest_dir` itself. `(dest_resolved / \"escape/payload\").resolve()` returns `dest_resolved/escape/payload`, lexically, because no `escape` symlink yet exists for `resolve` to follow.\n\nBy the time the second member writes, an `escape` symlink does exist on disk. The validator's resolve and the extractor's open ran against different filesystems. The first contained `dest_dir` alone. The second contained `dest_dir` plus a symlink the first filesystem did not contain. The symlink came from a member the validator saw and approved.\n\nThis is not a race condition. The two phases are deterministic and ordered: `_safe_extractall` validates every member, then `tar.extractall` extracts every member. The validator runs to completion before any disk write happens. The judgment is correct against the filesystem the validator inspected. The write happens against the next one.\n\n## The patch closes the gap twice.\n\nCommit [`0cec9fd`](https://github.com/MervinPraison/PraisonAI/commit/0cec9fd1c3fc457c70712d97e21ea1caaa32ecda) lands on May 4, 2026. Nineteen added lines in `_safe_extractall`. Two separate defenses.\n\nThe first defense is a `member.linkname` validation block that mirrors the existing `member.name` block:\n\n```python\nif member.issym() or member.islnk():\n linkname = member.linkname or \"\"\n if linkname.startswith(\"/\"):\n raise RegistryError(\n f\"Refusing to extract link with absolute target: {member.name} -> {linkname}\"\n )\n link_target = (dest_resolved / member_path.parent / linkname).resolve()\n if not str(link_target).startswith(str(dest_resolved) + os.sep) and link_target != dest_resolved:\n raise RegistryError(\n f\"Refusing to extract link escaping target directory: {member.name} -> {linkname}\"\n )\n```\n\nThe shape of this block is the shape of the original `member.name` block, duplicated for the field the original block did not inspect.\n\nThe second defense changes the `extractall` call:\n\n```diff\n- tar.extractall(dest_dir)\n+ try:\n+ tar.extractall(dest_dir, filter=\"data\")\n+ except TypeError:\n+ # filter keyword not supported on Python < 3.12; validation above already covers safety\n+ tar.extractall(dest_dir)\n```\n\n`filter=\"data\"` is [PEP 706](https://peps.python.org/pep-0706/), accepted September 2023, implemented in Python 3.12, default behavior in Python 3.14. The `\"data\"` filter is the standard library's name for what `_safe_extractall` is trying to be: an extractor that refuses symlinks with absolute targets, refuses symlinks whose linkname traverses outside the destination, refuses members with `..` in their names, and refuses absolute member names. The PEP 706 rationale section lists every Python project's variant of this same bug as the motivation for adding the filter to the stdlib.\n\nThe patched function tries `filter=\"data\"` first and falls back to the hand-rolled validator only when the filter raises `TypeError`. On Python 3.12 and above, the validation block is now redundant; the filter does the work. The validation block exists to cover Python 3.8 through 3.11.\n\nPre-patch, the function had neither defense. The hand-rolled validator did less than the stdlib filter that was the patch's eventual answer, and the call to `tar.extractall` did not invoke the filter at all. The hardening, the stdlib delegation, and the linkname check arrived in one commit. They had been missing in three different ways.\n\n## The bug class is older than the function.\n\n`tarfile`'s arbitrary-write-via-symlink primitive was first filed as [CVE-2007-4559](https://nvd.nist.gov/vuln/detail/CVE-2007-4559) against Python in August 2007. The advisory sat at \"won't fix\" for fifteen years until Trellix re-publicized it in 2022 with a survey claiming over 350,000 affected open-source projects. Python's response was [PEP 706](https://peps.python.org/pep-0706/), which added the `data` and `tar` filters in October 2023 and arranged for `data` to become the implicit default in Python 3.14. From Python 3.12 forward, every `tar.extractall()` call without an explicit `filter=` argument emits a `DeprecationWarning` whose stated purpose is to make calls exactly like the pre-patch one in `_safe_extractall` audible.\n\nThe hand-rolled validator inside `_safe_extractall` is what a Python developer writes when they have read about CVE-2007-4559 and decided to handle it themselves. The variants of the function are documented well enough that there are blog posts comparing them. Every variant in those blog posts ships the `member.name` check. Some ship a `..` rejection. A minority ship a `member.linkname` check. PraisonAI's variant is the majority shape. The majority shape is what PEP 706 was written to replace, because the majority shape misses the same field PraisonAI's variant did.\n\n## Three callers. One name.\n\n`_safe_extractall` is reached from three call sites. `LocalRegistry.unpack` at `registry.py:430`. `HTTPRegistry.pull` at `registry.py:825`. The CLI `recipe unpack` command at `cli/features/recipe.py:1175`. Every flow that opens a `.praison` bundle on a user's disk routes through this function.\n\nThe advisory's threat model lists three vectors: users unpacking malicious bundles from shared registries or tutorials, `praisonai recipe pull` operations against compromised registries, and registry servers processing uploaded bundles during validation. Of these, the local-unpack and registry-pull paths are present in the current codebase. The registry-server path is hypothetical against this repository; no server-side bundle-processing service ships here. The first two paths are the documented happy paths for moving a recipe from a tar bundle to an on-disk directory.\n\nWhat the three call sites share is a function whose name is `_safe_extractall`. The underscore prefix is the Python convention for internal API. The `safe_` qualifier is editorial. Nothing about Python forced the author to pick that name; it was a claim about a property the function was supposed to have. The property covered absolute paths in `member.name`, `..` segments in `member.name`, and resolved escape from `member.name`. It did not cover anything reachable through `member.linkname`. The function's name is what made the three callers comfortable handing it an attacker-controlled archive.\n\n## This is the validated-one-filename failure mode, written against tarfile.\n\nThe shape this bug embodies has a name in our catalog: Validated Source, Not Destination. The pattern was coined for [CVE-2026-0740 in Ninja Forms](https://nefariousplan.com/posts/ninja-forms-cve-2026-0740-two-patches), where a multipart upload handler validated `$_FILES['source'].name` for a permitted extension and wrote the bytes under a destination basename the form's JavaScript supplied as a separate POST parameter. Validation ran on one identifier of the upload record; the operation ran through a different identifier of the same record.\n\n`_safe_extractall` is the same shape rewritten against the Python `tarfile` module. `tarfile.TarInfo` is a record with two filename-shaped fields. `name` describes the path the member writes to inside the archive's notional root. `linkname` describes the path a `SYMTYPE` or `LNKTYPE` member redirects to. The validator inspected the field the developer perceived as \"where the file lives.\" The kernel followed the field the developer perceived as \"metadata about the link.\" Validating one identifier on a structured record does not validate the other. The pattern recurs in any file-handling primitive whose data model carries more than one identifier per record, and `tarfile.TarInfo.linkname` is one of the oldest examples in the Python standard library.\n\n## The fix landed four days before the CVE published.\n\nThe commit message for `0cec9fd` reads, in its entirety:\n\n> refactor: harden archive extraction and tool resolution boundary\n\nThe author attribution is `Cascade `, the generated commit identity from the Windsurf editor. The commit date is May 4, 2026. The CVE was published on May 8, 2026.\n\nThe word \"fix\" does not appear in the commit message. The word \"security\" does not appear in the commit message. The string `GHSA-9q28-ghcr-c4x3` does not appear in the commit message. CVE-2026-44340 does not appear in the commit message, because the CVE had not been assigned yet.\n\nThe same commit adds a test file, `src/praisonai/tests/unit/recipe/test_safe_extractall_symlink.py`. The module docstring reads:\n\n```python\n\"\"\"Regression tests for GHSA-9q28-ghcr-c4x3:\nSymlink-extraction bypass of _safe_extractall writes outside dest_dir.\n\"\"\"\n```\n\nThe four cases the test file exercises are: a clean archive that extracts as expected, a symlink with an absolute linkname, a symlink with a `../../outside` linkname, a hardlink with a `../../etc/passwd` linkname. The test file is named for the attack class. The test file's docstring names the GHSA. The cases the test file covers are the cases the pre-patch function got wrong.\n\nThe test file knows what the commit message does not say.\n\nAdvisory: [GHSA-9q28-ghcr-c4x3](https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-9q28-ghcr-c4x3). Reporter: Dhiral Vyas (@DHIRAL2908).","closing_line":"The function inspected the field whose name describes where the file lives. The bypass lives in the other field.","hook_md":"The function is named `_safe_extractall`. It lives at `src/praisonai/praisonai/recipe/registry.py:131` in PraisonAI through release 4.6.36. The pre-patch version validates three things about every tarfile member it sees: that `member.name` is not absolute, that `member.name` does not contain `..`, and that `(dest_dir / member.name).resolve()` does not escape `dest_dir`. The function then calls `tar.extractall(dest_dir)` with no `filter` argument.\n\nA tarfile member of type `SYMTYPE` or `LNKTYPE` has two strings. `_safe_extractall` reads one of them. CVE-2026-44340 is the other one.","post_id":275,"slug":"praisonai-safe-extractall-checked-name-not-linkname","title":"CVE-2026-44340: PraisonAI's _safe_extractall Validates member.name. The Bypass Is in member.linkname.","type":"initial","unreadable_sentence":"A tarfile member of type SYMTYPE or LNKTYPE has two strings. _safe_extractall reads one of them. CVE-2026-44340 is the other one."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCasfM3QAKCRDeZjl4jgkQ JhoUAQCuWdoQLCSQUqx8wXKRAF/np5CA1d77GuuJ+hRdlXTnTwD/ctBB8auf1+AC Vxc6sg1n+830IMggegRVRdvkVA7knAw= =mslC -----END PGP SIGNATURE-----