The validator inspected one field. The kernel follows the other.
When 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.
Here is the pre-patch loop, from src/praisonai/praisonai/recipe/registry.py:131-178:
def _safe_extractall(tar: tarfile.TarFile, dest_dir: Path) -> None:
MAX_SIZE = 100 * 1024 * 1024
MAX_FILES = 1000
total_size = 0
file_count = 0
dest_resolved = dest_dir.resolve()
for member in tar.getmembers():
file_count += 1
if file_count > MAX_FILES:
raise RegistryError(f"Archive contains too many files (>{MAX_FILES})")
total_size += member.size
if total_size > MAX_SIZE:
raise RegistryError(f"Archive is too large uncompressed (>{MAX_SIZE} bytes)")
member_path = Path(member.name)
if member_path.is_absolute():
raise RegistryError(
f"Refusing to extract absolute path in archive: {member.name}"
)
if '..' in member_path.parts:
raise RegistryError(
f"Refusing to extract path traversal in archive: {member.name}"
)
resolved = (dest_resolved / member_path).resolve()
if not str(resolved).startswith(str(dest_resolved) + os.sep) and resolved != dest_resolved:
raise RegistryError(
f"Refusing to extract path escaping target directory: {member.name}"
)
tar.extractall(dest_dir)
Every 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.
The 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.
Extraction 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.
The 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.
The validator resolved against an empty directory. The extractor wrote against a populated one.
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.
By 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.
This 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.
The patch closes the gap twice.
Commit 0cec9fd lands on May 4, 2026. Nineteen added lines in _safe_extractall. Two separate defenses.
The first defense is a member.linkname validation block that mirrors the existing member.name block:
if member.issym() or member.islnk():
linkname = member.linkname or ""
if linkname.startswith("/"):
raise RegistryError(
f"Refusing to extract link with absolute target: {member.name} -> {linkname}"
)
link_target = (dest_resolved / member_path.parent / linkname).resolve()
if not str(link_target).startswith(str(dest_resolved) + os.sep) and link_target != dest_resolved:
raise RegistryError(
f"Refusing to extract link escaping target directory: {member.name} -> {linkname}"
)
The shape of this block is the shape of the original member.name block, duplicated for the field the original block did not inspect.
The second defense changes the extractall call:
- tar.extractall(dest_dir)
+ try:
+ tar.extractall(dest_dir, filter="data")
+ except TypeError:
+ # filter keyword not supported on Python < 3.12; validation above already covers safety
+ tar.extractall(dest_dir)
filter="data" is PEP 706, 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.
The 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.
Pre-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.
The bug class is older than the function.
tarfile's arbitrary-write-via-symlink primitive was first filed as 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, 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.
The 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.
Three callers. One name.
_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.
The 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.
What 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.
This is the validated-one-filename failure mode, written against tarfile.
The 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, 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.
_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.
The fix landed four days before the CVE published.
The commit message for 0cec9fd reads, in its entirety:
refactor: harden archive extraction and tool resolution boundary
The author attribution is Cascade <[email protected]>, the generated commit identity from the Windsurf editor. The commit date is May 4, 2026. The CVE was published on May 8, 2026.
The 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.
The same commit adds a test file, src/praisonai/tests/unit/recipe/test_safe_extractall_symlink.py. The module docstring reads:
"""Regression tests for GHSA-9q28-ghcr-c4x3:
Symlink-extraction bypass of _safe_extractall writes outside dest_dir.
"""
The 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.
The test file knows what the commit message does not say.
Advisory: GHSA-9q28-ghcr-c4x3. Reporter: Dhiral Vyas (@DHIRAL2908).
The function inspected the field whose name describes where the file lives. The bypass lives in the other field.