Skip to content

fix(security): validate remote result archives - #620

Open
njzjz-bot wants to merge 1 commit into
deepmodeling:masterfrom
njzjz-bot:fix/issue-604-safe-archive-extraction
Open

fix(security): validate remote result archives#620
njzjz-bot wants to merge 1 commit into
deepmodeling:masterfrom
njzjz-bot:fix/issue-604-safe-archive-extraction

Conversation

@njzjz-bot

@njzjz-bot njzjz-bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add shared safe tar and zip extraction helpers for downloaded remote results
  • reject absolute/traversal paths, portable path collisions, links, devices, symlink and Windows junction destinations
  • stream files through temporary files and atomic replacement to avoid modifying existing hardlinked inodes
  • apply the validation to SSH, HDFS, OpenAPI, and legacy cloud extraction paths

Validation

  • python -m coverage run -p --source=./dpdispatcher -m unittest: 176 passed, 43 skipped
  • ruff check .: passed
  • ruff format --check .: passed
  • focused archive security tests: 13 passed on Linux with the Windows junction test skipped as platform-specific
  • CLI smoke tests: passed
  • ty check: reports four pre-existing allow_ref/dargs diagnostics in unchanged files
  • full pre-commit and Sphinx initialization were attempted but external Node/intersphinx downloads stalled; the documented Ruff fallback passed

Closes #604

Coding agent: Codex
Codex version: codex-cli 0.144.4
Model: gpt-5.6-sol
Reasoning effort: xhigh

Summary by CodeRabbit

  • Bug Fixes

    • Improved the safety of TAR and ZIP archive extraction.
    • Blocks path traversal, absolute paths, duplicate conflicts, symlinks, links, and special files from writing outside the intended destination.
    • Applies safer handling when downloading and extracting results through HDFS, SSH, OpenAPI, and cloud workflows.
  • Tests

    • Added comprehensive coverage for safe archive extraction, including malicious paths, filesystem links, and special file types.

Validate tar and zip manifests before extraction, reject unsafe paths and file types, and write regular files through atomic replacements across all remote result backends.

Closes deepmodeling#604

Coding-Agent: Codex
Codex-Version: codex-cli 0.144.4
Model: gpt-5.6-sol
Reasoning-Effort: xhigh
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Jul 18, 2026
@codecov

codecov Bot commented Jul 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.32487% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 50.39%. Comparing base (e23bb8c) to head (de9ba65).

Files with missing lines Patch % Lines
dpdispatcher/utils/archive.py 91.71% 15 Missing ⚠️
dpdispatcher/contexts/openapi_context.py 50.00% 2 Missing ⚠️
dpdispatcher/machines/openapi.py 50.00% 2 Missing ⚠️
dpdispatcher/utils/dpcloudserver/zip_file.py 50.00% 2 Missing ⚠️
dpdispatcher/contexts/hdfs_context.py 50.00% 1 Missing ⚠️
dpdispatcher/contexts/ssh_context.py 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #620      +/-   ##
==========================================
+ Coverage   48.38%   50.39%   +2.01%     
==========================================
  Files          40       41       +1     
  Lines        3960     4143     +183     
==========================================
+ Hits         1916     2088     +172     
- Misses       2044     2055      +11     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds centralized safe tar and zip extraction with path, member, symlink, special-file, and atomic-write validation. HDFS, SSH, OpenAPI, and cloud-server result downloads now use these helpers, with comprehensive security tests.

Changes

Archive extraction safety

Layer / File(s) Summary
Archive validation and extraction helpers
dpdispatcher/utils/archive.py
Adds UnsafeArchiveError, manifest validation, filesystem safety checks, atomic file replacement, and safe tar/zip extraction entrypoints.
Remote result extraction integration
dpdispatcher/contexts/*, dpdispatcher/machines/openapi.py, dpdispatcher/utils/dpcloudserver/zip_file.py
Replaces direct archive extraction with validated helpers and adds typed ZIP helper signatures.
Tar and zip safety tests
tests/test_archive.py
Tests valid extraction and rejection of traversal, conflicts, symlinks, junctions, hardlinks, links, and special files.

Estimated code review effort: 4 (Complex) | ~60 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title is concise and matches the main change: securing remote archive extraction.
Linked Issues check ✅ Passed The PR adds safe tar/zip extraction and applies it to HDFS, SSH, and cloud result extraction as requested.
Out of Scope Changes check ✅ Passed The OpenAPI and shared archive helper changes support the same archive-safety work and are not unrelated.
Docstring Coverage ✅ Passed Docstring coverage is 93.75% which is sufficient. The required threshold is 80.00%.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
tests/test_archive.py (2)

82-86: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Cover additional portable path collisions.

Add case-folding and trailing-dot aliases such as Result.txt/result.txt and result.txt/result.txt.. The current manifests only exercise ./ and Unicode-normalization aliases.

Also applies to: 255-259

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_archive.py` around lines 82 - 86, Add case-folding and
trailing-dot collision pairs to the unsafe_manifests test data in the archive
safety tests, including Result.txt/result.txt and result.txt/result.txt.
Preserve the existing dot-slash, Unicode-normalization, and parent-child
collision cases.

76-78: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that validation does not create the output root.

These assertions allow an empty output_dir to be created before validation, contrary to safe_extract_tar and safe_extract_zip’s documented contract.

Proposed assertion
-                    self.assertFalse(
-                        os.path.exists(os.path.join(output_dir, "valid.txt"))
-                    )
+                    self.assertFalse(os.path.exists(output_dir))

Also applies to: 249-251

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_archive.py` around lines 76 - 78, Update the validation assertions
in the archive extraction tests around the valid.txt checks to also assert that
the output_dir itself does not exist after validation rejects the archive. Apply
the same assertion to both the tar and zip cases, preserving the existing checks
that no output file is created.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dpdispatcher/utils/archive.py`:
- Around line 155-163: Update _prepare_root to reject a pre-existing symlink or
Windows junction at destination before calling realpath or validating the
resolved directory. Raise UnsafeArchiveError for these link-based roots, while
preserving normal directory creation and resolution for genuine directories.
- Around line 141-147: Update the manifest validation around the non-directory
branch to track encountered directory prefixes in a dedicated set rather than
scanning all entries in path_types for each file. Use that set to detect
file-versus-directory conflicts while preserving the existing UnsafeArchiveError
and canonical_path behavior.

In `@tests/test_archive.py`:
- Around line 102-124: Add root-destination tests alongside
test_rejects_existing_symlink_components and the related cases, covering both
public extraction entry points. Make output_dir itself a symlink or Windows
junction to an outside directory, invoke each extractor with an archive, and
assert UnsafeArchiveError is raised without creating files outside the trusted
destination. Preserve platform skips where symlinks or junctions are
unavailable.

---

Nitpick comments:
In `@tests/test_archive.py`:
- Around line 82-86: Add case-folding and trailing-dot collision pairs to the
unsafe_manifests test data in the archive safety tests, including
Result.txt/result.txt and result.txt/result.txt. Preserve the existing
dot-slash, Unicode-normalization, and parent-child collision cases.
- Around line 76-78: Update the validation assertions in the archive extraction
tests around the valid.txt checks to also assert that the output_dir itself does
not exist after validation rejects the archive. Apply the same assertion to both
the tar and zip cases, preserving the existing checks that no output file is
created.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 447a8546-443d-42be-b674-2f42c3a22cbc

📥 Commits

Reviewing files that changed from the base of the PR and between e23bb8c and de9ba65.

📒 Files selected for processing (7)
  • dpdispatcher/contexts/hdfs_context.py
  • dpdispatcher/contexts/openapi_context.py
  • dpdispatcher/contexts/ssh_context.py
  • dpdispatcher/machines/openapi.py
  • dpdispatcher/utils/archive.py
  • dpdispatcher/utils/dpcloudserver/zip_file.py
  • tests/test_archive.py

Comment on lines +141 to +147
if not is_directory:
descendant_prefix = path_key + "/"
if any(key.startswith(descendant_prefix) for key in path_types):
raise UnsafeArchiveError(
"Archive file conflicts with an existing directory member: "
f"{canonical_path!r}"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Avoid quadratic manifest validation on remote archives.

Each file scans every previously seen path, so an archive with many sibling files causes O(n²) validation and can stall result downloads. Track previously encountered parent paths in a set instead.

Proposed fix
     validated: List[_ValidatedMember] = []
     path_types: Dict[str, bool] = {}
+    parent_paths = set()

     for source, name, is_directory, mode in candidates:
         parts = _canonical_member_parts(name, is_directory)
         canonical_path = "/".join(parts)
         path_key = unicodedata.normalize("NFC", canonical_path).casefold()
+        member_parent_paths = []

         for index in range(1, len(parts)):
             parent_key = unicodedata.normalize(
                 "NFC", "/".join(parts[:index])
             ).casefold()
+            member_parent_paths.append(parent_key)
             if path_types.get(parent_key) is False:
                 raise UnsafeArchiveError(...)

         if not is_directory:
-            descendant_prefix = path_key + "/"
-            if any(key.startswith(descendant_prefix) for key in path_types):
+            if path_key in parent_paths:
                 raise UnsafeArchiveError(...)

         path_types[path_key] = is_directory
+        parent_paths.update(member_parent_paths)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if not is_directory:
descendant_prefix = path_key + "/"
if any(key.startswith(descendant_prefix) for key in path_types):
raise UnsafeArchiveError(
"Archive file conflicts with an existing directory member: "
f"{canonical_path!r}"
)
validated: List[_ValidatedMember] = []
path_types: Dict[str, bool] = {}
parent_paths = set()
for source, name, is_directory, mode in candidates:
parts = _canonical_member_parts(name, is_directory)
canonical_path = "/".join(parts)
path_key = unicodedata.normalize("NFC", canonical_path).casefold()
member_parent_paths = []
for index in range(1, len(parts)):
parent_key = unicodedata.normalize(
"NFC", "/".join(parts[:index])
).casefold()
member_parent_paths.append(parent_key)
if path_types.get(parent_key) is False:
raise UnsafeArchiveError(
"Archive file conflicts with an existing directory member: "
f"{canonical_path!r}"
)
if not is_directory:
if path_key in parent_paths:
raise UnsafeArchiveError(
"Archive file conflicts with an existing directory member: "
f"{canonical_path!r}"
)
path_types[path_key] = is_directory
parent_paths.update(member_parent_paths)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dpdispatcher/utils/archive.py` around lines 141 - 147, Update the manifest
validation around the non-directory branch to track encountered directory
prefixes in a dedicated set rather than scanning all entries in path_types for
each file. Use that set to detect file-versus-directory conflicts while
preserving the existing UnsafeArchiveError and canonical_path behavior.

Comment on lines +155 to +163
def _prepare_root(destination: str) -> str:
"""Create and resolve the trusted extraction root."""
os.makedirs(destination, exist_ok=True)
root = os.path.realpath(os.path.abspath(destination))
if not os.path.isdir(root):
raise UnsafeArchiveError(
f"Archive extraction destination is not a directory: {destination!r}"
)
return root

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Reject symlink and junction extraction roots.

realpath() currently converts a destination link into its target before validation. A pre-existing symlink or Windows junction can therefore redirect every validated write outside the intended destination.

Proposed fix
 def _prepare_root(destination: str) -> str:
     """Create and resolve the trusted extraction root."""
-    os.makedirs(destination, exist_ok=True)
-    root = os.path.realpath(os.path.abspath(destination))
-    if not os.path.isdir(root):
+    destination_path = os.path.abspath(destination)
+    os.makedirs(destination_path, exist_ok=True)
+    destination_stat = os.lstat(destination_path)
+    if _is_unsafe_link(destination_path, destination_stat):
+        raise UnsafeArchiveError(
+            f"Archive extraction destination is a link: {destination!r}"
+        )
+    if not stat.S_ISDIR(destination_stat.st_mode):
         raise UnsafeArchiveError(
             f"Archive extraction destination is not a directory: {destination!r}"
         )
-    return root
+    return os.path.realpath(destination_path)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _prepare_root(destination: str) -> str:
"""Create and resolve the trusted extraction root."""
os.makedirs(destination, exist_ok=True)
root = os.path.realpath(os.path.abspath(destination))
if not os.path.isdir(root):
raise UnsafeArchiveError(
f"Archive extraction destination is not a directory: {destination!r}"
)
return root
def _prepare_root(destination: str) -> str:
"""Create and resolve the trusted extraction root."""
destination_path = os.path.abspath(destination)
os.makedirs(destination_path, exist_ok=True)
destination_stat = os.lstat(destination_path)
if _is_unsafe_link(destination_path, destination_stat):
raise UnsafeArchiveError(
f"Archive extraction destination is a link: {destination!r}"
)
if not stat.S_ISDIR(destination_stat.st_mode):
raise UnsafeArchiveError(
f"Archive extraction destination is not a directory: {destination!r}"
)
return os.path.realpath(destination_path)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dpdispatcher/utils/archive.py` around lines 155 - 163, Update _prepare_root
to reject a pre-existing symlink or Windows junction at destination before
calling realpath or validating the resolved directory. Raise UnsafeArchiveError
for these link-based roots, while preserving normal directory creation and
resolution for genuine directories.

Comment thread tests/test_archive.py
Comment on lines +102 to +124
def test_rejects_existing_symlink_components(self) -> None:
"""Extraction never follows a symlink below the trusted output root."""
with tempfile.TemporaryDirectory() as temp_dir:
archive_path = os.path.join(temp_dir, "result.tar")
output_dir = os.path.join(temp_dir, "output")
outside_dir = os.path.join(temp_dir, "outside")
os.mkdir(output_dir)
os.mkdir(outside_dir)
try:
os.symlink(outside_dir, os.path.join(output_dir, "task"))
except (NotImplementedError, OSError) as error:
self.skipTest(f"symlinks are unavailable: {error}")

with tarfile.open(archive_path, "w") as archive:
self._add_file(archive, "valid.txt", b"valid")
self._add_file(archive, "task/result.txt", b"result")

with tarfile.open(archive_path, "r") as archive:
with self.assertRaisesRegex(UnsafeArchiveError, "symlink"):
safe_extract_tar(archive, output_dir)

self.assertFalse(os.path.exists(os.path.join(outside_dir, "result.txt")))
self.assertFalse(os.path.exists(os.path.join(output_dir, "valid.txt")))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Test redirects at the destination root itself.

These cases only redirect output/task; they do not pass a symlink or Windows junction as output_dir. Add root-destination cases for both public extractors to enforce the PR’s symlink/junction destination guarantee.

Also applies to: 151-176, 277-299

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_archive.py` around lines 102 - 124, Add root-destination tests
alongside test_rejects_existing_symlink_components and the related cases,
covering both public extraction entry points. Make output_dir itself a symlink
or Windows junction to an outside directory, invoke each extractor with an
archive, and assert UnsafeArchiveError is raised without creating files outside
the trusted destination. Preserve platform skips where symlinks or junctions are
unavailable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Code scan] Validate archive members before extracting remote results

1 participant