Skip to content

fix(templates): reject path traversal, absolute paths, and symlinks during archive extraction - #766

Open
TheWeirdDee wants to merge 4 commits into
Nanle-code:masterfrom
TheWeirdDee:fix/684-archive-path-traversal
Open

fix(templates): reject path traversal, absolute paths, and symlinks during archive extraction#766
TheWeirdDee wants to merge 4 commits into
Nanle-code:masterfrom
TheWeirdDee:fix/684-archive-path-traversal

Conversation

@TheWeirdDee

Copy link
Copy Markdown
Contributor

Closes #684.

Objective

Reject absolute paths, parent traversal, links, and files outside the destination root when extracting a downloaded/local .zip template package.

What was there before

templates::extract_zip_archive (src/utils/templates.rs) — used by both registry install (downloading a template archive from a remote registry) and local .zip template sources — already had a partial zip-slip guard:

let entry_path = match entry.enclosed_name() {
    Some(p) => p.to_path_buf(),
    None => continue,   // <-- silently skips the entry
};
...
if !out_path.starts_with(&dest_canon) {
    anyhow::bail!("... escapes the destination directory (zip-slip)");
}

enclosed_name() (from the zip crate) already returns None for entries with an absolute path or a .. component that would escape the archive root — so those were being caught. But two things were missing:

  1. Absolute paths / parent traversal were silently skipped, not rejected. A malicious or corrupted archive would appear to extract "successfully" with the dangerous entries quietly dropped, rather than failing loudly. That's misleading — the caller has no idea the archive was tampered with.
  2. Symlink entries were never checked at all. Nothing looked at the entry's Unix mode bits, so a symlink entry would just get written out as a regular file containing the literal target-path bytes — not itself an escape (this code never calls a symlink-creation API), but not what the issue asks for either ("reject... links").

What changed

  • Absolute-path and parent-traversal entries (still detected via enclosed_name() == None) now make the whole extraction fail with a clear error naming the offending entry, instead of silently continuing to the next entry.
  • Added an explicit symlink check via entry.unix_mode() (masking for S_IFLNK), which also rejects the whole extraction with a clear error.
  • Kept the existing zip-slip (out_path resolves outside dest) check as a final defense-in-depth layer, since enclosed_name() already guarantees no ../absolute components make it that far — this check just makes the guarantee explicit rather than implicit.
  • No behavior change for well-formed archives: the existing happy-path test (nested nested single nested directory → normalize_template_rootvalidate_template_structure) still passes unmodified.

Tests added

  • extract_zip_archive_rejects_parent_traversal — an entry named ../escaped.txt fails extraction with a clear error, and nothing is written outside the destination.
  • extract_zip_archive_rejects_absolute_path — an entry named /etc/passwd-clone fails extraction with a clear error.
  • extract_zip_archive_stops_at_first_malicious_entry — a mixed archive (one legitimate file, one traversal entry) fails as a whole rather than partially extracting the legitimate file (boundary case: not a single-bad-entry archive).
  • is_symlink_mode_detects_symlink_and_ignores_other_types — unit test for the Unix-mode classification helper (regular file / directory / symlink bit patterns). I couldn't construct an actual symlink zip entry through the safe zip::write::ZipWriter API for an end-to-end test — FileOptions::unix_permissions() masks off the file-type bits by design (zip 0.6.6 has its own test, unix_permissions_bitmask, confirming this), and start_file() always forces the S_IFREG bit. So the classification logic is unit-tested directly against synthetic mode values instead, which is what's actually security-relevant here (the bit-masking check), independent of how a real symlink entry would reach it.

Documentation

Added an "Archive Extraction (Client-Side)" subsection to REMOTE_REGISTRY_IMPLEMENTATION.md's existing ## Security section, describing exactly what gets rejected and why (compatibility/security note per the issue's acceptance criteria).

Testing performed

cargo test --lib templates:: currently cannot complete on master in this repo — building the full test binary hits ~20+ pre-existing, unrelated compile errors elsewhere in the tree (missing struct fields on TemplateEntry in template_recommender.rs/template_analytics.rs/templates.rs's own older fixtures, a type mismatch on changelog, a mutable-borrow issue in ai.rs, etc. — see #759 for the same issue surfacing on a different feature). None of that is caused by or related to this change, and I did not touch any of those files.

To validate this specific fix independent of that, I copied extract_zip_archive + is_symlink_mode and the new tests into an isolated scratch crate (dependencies: anyhow, zip = "0.6", tempfile, matching what's pinned here) and ran them there:

running 5 tests
test tests::is_symlink_mode_detects_symlink_and_ignores_other_types ... ok
test tests::extract_zip_archive_rejects_parent_traversal ... ok
test tests::extract_zip_archive_rejects_absolute_path ... ok
test tests::extract_zip_archive_stops_at_first_malicious_entry ... ok
test tests::extract_zip_archive_happy_path ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

cargo fmt --check on the touched file is clean.

@drips-wave

drips-wave Bot commented Aug 26, 2026

Copy link
Copy Markdown

@TheWeirdDee Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Manuelshub

Copy link
Copy Markdown
Collaborator

@TheWeirdDee Please all failing CIs

…uring archive extraction

extract_zip_archive() (used by `registry install` and local `.zip`
template sources) silently skipped entries with an absolute path or
`..` parent traversal instead of rejecting the archive, and never
checked for symlink entries at all. Treat any of these as reason to
reject the whole archive rather than partially extracting an
otherwise-malicious/corrupted package:

- Absolute paths and parent-traversal components (already detected via
  zip::ZipFile::enclosed_name()) now produce a clear error instead of
  being silently dropped.
- Symlink entries are now detected via the entry's Unix mode bits and
  rejected explicitly.
- The existing zip-slip (resolves-outside-destination) check is kept
  as a final defense-in-depth layer.

Adds tests for parent-traversal rejection, absolute-path rejection, a
mixed archive that stops at the first malicious entry, and the
symlink-mode classification helper, alongside the existing happy-path
extraction test. Documents the behavior in
REMOTE_REGISTRY_IMPLEMENTATION.md's Security section.
master currently fails cargo test/cargo clippy --all-targets for
reasons entirely unrelated to this PR's actual change (archive
extraction safety) — a grab-bag of struct-shape drift and one real
missing feature, evidently left behind by several bad merges:

- bindings.rs: read_spec_entries was defined twice (a #[cfg(test)]
  pub copy duplicating the real, unconditional private fn). Removed
  the duplicate; the original is already used by production code.
- commands/audit.rs: `ci_passed` was specified twice in one
  AuditResult test literal.
- plugins/manifest.rs: two PluginManifest test literals predated the
  required_capabilities field.
- utils/compliance.rs: ComplianceSeverity was compared with `==` in a
  test but never derived PartialEq.
- utils/ai.rs: a test called `cb.is_available()` (&mut self) on a
  non-mut binding.
- utils/templates.rs, template_analytics.rs, template_recommender.rs:
  several TemplateEntry test literals predated the categories/
  featured/repository_url/changelog/repository/security_review
  fields added since; also two spots comparing/constructing an
  Option<Vec<ChangelogEntry>> as a bare Vec.
- plugins/registry.rs: this one wasn't just a stale test fixture.
  install_plugin() already took a `description: &str` parameter (per
  its own doc comment) but never stored it — InstalledPlugin had no
  description field at all, and the two helpers the tests expected
  (resolve_plugin_description, plugin_list_entries) didn't exist.
  Meanwhile `starforge plugin list`'s human-readable table hardcoded
  an empty string in the Description column. Added the field (with
  #[serde(default)] for old registry.json compatibility), wired
  install_plugin to store it, implemented both helpers (prefer the
  explicit description, fall back to the first command's), and
  switched both the JSON and table output in commands/plugin.rs to
  use them — fixing the always-blank Description column along the
  way.

cargo test --lib --all-features --no-run and
cargo clippy --all-features --locked -- -D warnings both now pass
clean.
The rebase onto master dropped the now-redundant thiserror Cargo.toml
addition (master already fixed the Migration trait signature this PR
originally needed thiserror for, independently). Cargo.lock needs to
match or --locked builds fail.
@TheWeirdDee
TheWeirdDee force-pushed the fix/684-archive-path-traversal branch from 9b3a8a2 to b1813f7 Compare August 27, 2026 08:12
Same batch of pre-existing bugs already fixed on fix/651-scoped-lints
(cherry-picked from there) - these are all inherited from upstream
master and equally affect this branch:

- tests/ai_test_assistant.rs: two of the three generator helpers it
  exercises (generate_edge_case_descriptions, generate_security_checks,
  generate_warnings) had been relocated from utils::ai_test_assistant
  into commands::ai_test as private fns, breaking the integration test.
  Moved them back to utils::ai_test_assistant as pub fns and pointed
  commands::ai_test at them instead of duplicating the logic. Also
  added a missing .unwrap() after analyze_contract_for_testing started
  returning a Result.
- tests/template_recommendation.rs: make_entry() predated the
  categories/featured/repository_url fields on TemplateEntry.
- cargo fmt --all: fixed formatting drift.
- deny.toml: removed a stale RUSTSEC-2026-0190 ignore that no longer
  matches any crate in the dependency tree.
- fuzz/Cargo.lock: regenerated; had drifted out of sync with
  fuzz/Cargo.toml.
- .github/workflows/benchmark-latency.yml: cargo bench passed three
  positional filters but Criterion's harness only accepts one -
  combined into a regex alternation. Also wrapped the PR-comment step
  in try/catch since fork PRs get a read-only GITHUB_TOKEN.
- templates/registry.json: SecurityReview.findings is Option<String>
  but the bundled seed data has always shipped numeric values, so the
  CLI's offline fallback path could never actually parse its own
  bundled registry. Converted to match the type; added a regression
  test.
- src/utils/database.rs: initialize() used
  `get_meta("schema_version").is_ok()` to decide whether a database
  was fresh, but Ok(None) is still Ok - broke every fresh install.
  Changed to `.is_some()`.
- src/utils/test_optimizer.rs: batch_tests_by_profile computed the
  non-IO-bound tests and then discarded them, batching an always-empty
  placeholder instead - CPU/memory-bound/general tests were silently
  dropped from every batch. save_state() didn't create config_dir
  before writing into it.
- tests/contract_property_tests.rs: two tests with incorrect premises
  (a byte-range that could never produce "short" input, and an
  auth-recording assertion checked before anything was recorded).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[2026 Templates] Prevent archive path traversal during extraction

2 participants