fix(backup): reject symlinks/hardlinks/device files in Python < 3.12 extractall fallback - #65579
xxiaoxiong wants to merge 1 commit into
Conversation
…extractall fallback (NousResearch#65556) The Python < 3.12 fallback for tarfile.extractall() lacked member validation against symlinks, hardlinks, and device/special files. Python 3.12+'s filter='data' automatically rejects these, making the fallback path a weaker security guarantee. Fix: iterate members before extractall in the fallback path, raising TarError on symlinks/hardlinks and non-regular files.
5618391 to
607ddb9
Compare
|
STT hunk was split out — now clean. |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused fallback hardening. The premise is live on current main: agent/curator_backup.py:624-634 validates names, then the Python <3.12 branch calls unfiltered extractall().
Problems
- The new
agent/curator_backup.py:638condition is stricter thanfilter="data", rather than equivalent to it. Currenttarfile._get_filtered_attrspermits symlink/hardlink members whose targets remain within the destination and rejects escaping targets. This can make a valid in-tree-link snapshot restore on Python 3.12+ but fail on Python 3.11. - The diff adds no regression test. Current coverage in
tests/agent/test_curator_backup.py:236-259only exercises unsafe path names, not a link-mediated write through the forced legacy fallback.
Suggested changes
- Define whether links are intentionally unsupported in snapshots; otherwise validate their resolved targets equivalently to
filter="data". - Add a forced-
TypeErrorfallback test proving an escaping symlink archive is rejected and staged skills are restored. Related open PR #23796 already contains a test and broader path-form validation worth comparing.
This is an automated hermes-sweeper review.
| # device/special files in addition to the path traversal | ||
| # check already done above. | ||
| for member in tf.getmembers(): | ||
| if member.issym() or member.islnk(): |
There was a problem hiding this comment.
filter="data" does not reject every link: it permits symlink/hardlink members whose targets resolve inside the destination. This makes Python <3.12 stricter than Python 3.12+ and can reject a snapshot that the newer path restores; please either preserve equivalent target-aware behavior or explicitly establish and test all-link rejection as the supported contract.
|
suggesting changes The fallback closes the demonstrated archive-link and special-file extraction paths, but it is stricter than the
Security evidence:
Not checked:
Signed: GPT-5.6-sol-xhigh in Codex |
SummaryTwo open PRs address the unsafe legacy tar extraction path in Related pull requests
Duplicates#60007 and #65579 substantially duplicate each other on legacy-fallback symlink and hardlink hardening; contributor discussion identifies both as downstream duplicates of #23796, with #65579 additionally rejecting special files. Suggested consolidationClose #60007 and #65579 as duplicates of #23796. The cross-PR evidence identifies #23796 as the earlier implementation for the same vulnerability, with the forced- Complex graphflowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
subgraph Dup60007 ["PRs duplicating each other"]
P60007["PR #60007 (open)"]
P65579["PR #65579 (open)"]
end
class P60007 open
class P65579 open
class P65579 target
click P60007 "https://github.com/NousResearch/hermes-agent/pull/60007"
click P65579 "https://github.com/NousResearch/hermes-agent/pull/65579"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label). Cross-PR triage: Reviewed 2 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 2 kB of PR diffs, 4 kB of issue/PR text, 5 kB of discussion (6 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
|
Closing stale PR — superseded by upstream work / no longer relevant. |
Problem (#65556)
agent/curator_backup.pyhas a pre-extraction path traversal check (lines 626–630) that validates member names for..and absolute paths before callingextractall(). The Python 3.12+ path usesfilter="data"which automatically rejects symlinks, hardlinks, and device nodes. The Python < 3.12 fallback path (line 634) callstf.extractall(str(skills))withoutfilter="data"and without any post-validation for member types.A malicious
.tar.gzcontaining a symlink that points outside the extraction directory, or a hardlink/device node, would be extracted by the fallback path even though the 3.12+ path would reject it.Fix
Replicate the
filter="data"protections manually in the Python < 3.12 fallback path. Iterate members before callingextractall()and raiseTarErroron:issym()— symlinks (could point outside the extraction root)islnk()— hardlinks (could reference existing system files)isfile()norisdir()— device nodes, FIFOs, socketsThe pre-extraction path traversal check (lines 626–630) already rejects
..and absolute paths; this change adds the missing member-type validation for the fallback path.try: tf.extractall(str(skills), filter="data") # type: ignore[call-arg] except TypeError: - # Python < 3.12 — no filter kwarg + # Python < 3.12 — no filter kwarg. Replicate the same + # protections manually: reject symlinks, hardlinks, and + # device/special files in addition to the path traversal + # check already done above. + for member in tf.getmembers(): + if member.issym() or member.islnk(): + raise tarfile.TarError( + f"refusing to extract symlink/hardlink: {member.name!r}" + ) + if not member.isfile() and not member.isdir(): + raise tarfile.TarError( + f"refusing to extract non-regular file: {member.name!r}" + ) tf.extractall(str(skills))Test plan
filter="data"(path unaffected).