Skip to content

🛡️ Sentinel: Harden link target extraction against memory exhaustion#3069

Closed
ChanTsune wants to merge 2 commits into
mainfrom
sentinel/bound-link-target-size-6585411856353083135
Closed

🛡️ Sentinel: Harden link target extraction against memory exhaustion#3069
ChanTsune wants to merge 2 commits into
mainfrom
sentinel/bound-link-target-size-6585411856353083135

Conversation

@ChanTsune
Copy link
Copy Markdown
Owner

@ChanTsune ChanTsune commented May 17, 2026

🛡️ Sentinel: [HIGH] Harden link target extraction against memory exhaustion

  • 🚨 Severity: HIGH
  • 💡 Vulnerability: Potential Denial of Service (DoS) via memory exhaustion when reading excessively large symbolic or hard link targets from a malformed archive.
  • 🎯 Impact: An attacker could craft an archive with a link target payload (stored in the archive and decompressed on the fly) that is much larger than available memory, causing the archiver to crash or become unresponsive during extraction, listing, or comparison.
  • 🔧 Fix: Introduced a MAX_LINK_TARGET_SIZE constant set to 64 KiB (a generous limit considering standard OS path limits like 4096 bytes). Modified the extract, list, and diff commands to use reader.take(MAX_LINK_TARGET_SIZE + 1) when reading link targets and verify that the actual length does not exceed the limit.
  • Verification: Added new integration tests in cli/tests/cli/extract/link_limit.rs that verify the archiver correctly rejects archives with link targets exceeding the 64 KiB limit. All tests passed.
cargo test -p portable-network-archive --test cli link_limit

PR created automatically by Jules for task 6585411856353083135 started by @ChanTsune

Summary by CodeRabbit

Bug Fixes

  • Archive extraction and listing operations now enforce a 64 KiB limit on link target sizes; operations exceeding this limit will fail with an error message.

Tests

  • Added tests to verify enforcement of link target size limits during extraction.

Review Change Stack

Define `MAX_LINK_TARGET_SIZE` (64 KiB) and enforce this limit when reading
symbolic and hard link targets from archives. This prevents a potential
denial-of-service (DoS) attack where a malicious archive contains
excessively large link targets that exhaust available memory during
extraction, listing, or comparison.

Changes:
- Added `MAX_LINK_TARGET_SIZE` constant in `cli/src/command/core.rs`.
- Enforced size limit in `cli/src/command/extract.rs`, `cli/src/command/list.rs`, and `cli/src/command/diff.rs`.
- Added integration tests in `cli/tests/cli/extract/link_limit.rs` to verify enforcement.
- Updated `.jules/sentinel.md` with security learnings.

Co-authored-by: ChanTsune <41658782+ChanTsune@users.noreply.github.com>
@google-labs-jules
Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 17, 2026

Warning

Rate limit exceeded

@ChanTsune has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 25 minutes and 12 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2bac1581-230a-4b57-9463-c8c07c118107

📥 Commits

Reviewing files that changed from the base of the PR and between 74887ee and 4b6b8be.

📒 Files selected for processing (2)
  • cli/src/command/extract.rs
  • cli/tests/cli/extract.rs
📝 Walkthrough

Walkthrough

This PR hardens the pna archive extraction tool against denial-of-service attacks by enforcing a 64 KiB maximum size limit on symlink and hardlink target reading. The change is applied consistently across diff, extract, and list commands with integration tests validating the behavior.

Changes

Link target size limit enforcement

Layer / File(s) Summary
Define link target size constant
cli/src/command/core.rs, .jules/sentinel.md
MAX_LINK_TARGET_SIZE constant (64 KiB) is introduced with documentation on the memory exhaustion vulnerability and the mitigation strategy.
Diff command link target bounds
cli/src/command/diff.rs
Imports MAX_LINK_TARGET_SIZE and applies bounded read logic to both symbolic and hard link target comparison; returns InvalidData error if target exceeds the limit.
Extract command link target bounds
cli/src/command/extract.rs
Applies bounded read logic to symbolic and hard link extraction with import reflow; reads up to MAX_LINK_TARGET_SIZE + 1 bytes and rejects oversized targets with InvalidData error.
List command link target bounds
cli/src/command/list.rs
Updates TableRow::from_entry to enforce the limit for both link types during listing; preserves "-" fallback on read errors while rejecting oversized targets with InvalidData error.
Integration tests for link target limit
cli/tests/cli/extract/link_limit.rs, cli/tests/cli/extract.rs
Adds tests that verify pna x command rejects archived symlink and hardlink entries with targets exceeding 64 KiB, asserting failure and stderr message "link target too long".

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • ChanTsune/Portable-Network-Archive#2947: Both PRs modify cli/src/command/extract.rs's symlink/hardlink extraction flow; this PR adds bounded reading and error handling, while the related PR refactors link handling into a separate function.

Suggested labels

cli

Poem

🐰 A limit most wise, sixty-four ki's the key,
Taming the link target's unbounded spree,
Through diff and extract and list they now roam,
Each link target bounded—a safer archive home!
Tested and true, no memory will flee. 🛡️

🚥 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 The title clearly summarizes the main change: hardening link target extraction against memory exhaustion (DoS via unbounded reads).
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sentinel/bound-link-target-size-6585411856353083135

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 and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
cli/tests/cli/extract/link_limit.rs (1)

16-21: ⚡ Quick win

Add an exact-limit acceptance test to lock in the boundary.

You already test MAX + 1; please also test exactly 64 * 1024 bytes succeeds (for at least one link type) to prevent off-by-one regressions. Using a local const MAX_LINK_TARGET_SIZE in this test file would also remove duplicated magic numbers.

Proposed test tweak
+const MAX_LINK_TARGET_SIZE: usize = 64 * 1024;
+
 #[test]
 fn extract_fails_on_huge_symlink_target() {
@@
-    let huge_target = "a".repeat(65536 + 1);
+    let huge_target = "a".repeat(MAX_LINK_TARGET_SIZE + 1);
@@
 }
+
+#[test]
+fn extract_succeeds_on_max_symlink_target() {
+    setup();
+    let base_dir = "extract_succeeds_on_max_symlink_target";
+    fs::create_dir_all(format!("{base_dir}/out")).unwrap();
+
+    let archive_path = format!("{base_dir}/archive.pna");
+    let archive_file = fs::File::create(&archive_path).unwrap();
+    let mut archive = pna::Archive::write_header(archive_file).unwrap();
+
+    let max_target = "a".repeat(MAX_LINK_TARGET_SIZE);
+    let entry = pna::EntryBuilder::new_symlink(
+        pna::EntryName::from("max_link"),
+        pna::EntryReference::from_utf8_preserve_root(&max_target),
+    )
+    .unwrap()
+    .build()
+    .unwrap();
+    archive.add_entry(entry).unwrap();
+    archive.finalize().unwrap();
+
+    let output = cargo_bin_cmd!("pna")
+        .arg("x")
+        .arg(&archive_path)
+        .arg("--out-dir")
+        .arg(format!("{base_dir}/out"))
+        .arg("--overwrite")
+        .output()
+        .unwrap();
+
+    assert!(output.status.success());
+}

Also applies to: 55-59

🤖 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 `@cli/tests/cli/extract/link_limit.rs` around lines 16 - 21, Add an exact-limit
acceptance test by defining a local const MAX_LINK_TARGET_SIZE = 64 * 1024 in
cli/tests/cli/extract/link_limit.rs and use it to create a target with
"a".repeat(MAX_LINK_TARGET_SIZE) (in addition to the existing MAX+1 case).
Construct the entry with pna::EntryName::from("huge_link_exact") and
pna::EntryReference::from_utf8_preserve_root(&huge_target) and assert that
creating/accepting this link succeeds for at least one link type used in the
file; this removes the duplicated magic number and locks the 64 KiB boundary to
avoid off-by-one regressions.
🤖 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.

Nitpick comments:
In `@cli/tests/cli/extract/link_limit.rs`:
- Around line 16-21: Add an exact-limit acceptance test by defining a local
const MAX_LINK_TARGET_SIZE = 64 * 1024 in cli/tests/cli/extract/link_limit.rs
and use it to create a target with "a".repeat(MAX_LINK_TARGET_SIZE) (in addition
to the existing MAX+1 case). Construct the entry with
pna::EntryName::from("huge_link_exact") and
pna::EntryReference::from_utf8_preserve_root(&huge_target) and assert that
creating/accepting this link succeeds for at least one link type used in the
file; this removes the duplicated magic number and locks the 64 KiB boundary to
avoid off-by-one regressions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 72c50548-b05a-4037-94b2-14ee3c988f99

📥 Commits

Reviewing files that changed from the base of the PR and between 681316d and 74887ee.

📒 Files selected for processing (7)
  • .jules/sentinel.md
  • cli/src/command/core.rs
  • cli/src/command/diff.rs
  • cli/src/command/extract.rs
  • cli/src/command/list.rs
  • cli/tests/cli/extract.rs
  • cli/tests/cli/extract/link_limit.rs

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements a 64 KiB limit on link target sizes across the diff, extract, and list commands to mitigate potential Denial of Service (DoS) attacks via memory exhaustion. The changes include documentation in a sentinel file and new integration tests to verify the enforcement of this limit. The reviewer recommends refactoring the duplicated size-checking and reading logic into a centralized helper function in core.rs to improve code maintainability and ensure consistent error handling across the CLI.

Comment thread cli/src/command/core.rs
Comment on lines +156 to +158
/// Maximum size of a link target in bytes.
/// Prevents memory exhaustion when reading link targets from untrusted archives.
pub(crate) const MAX_LINK_TARGET_SIZE: usize = 64 * 1024;
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.

medium

The logic for reading a link target with a size limit is duplicated across list, diff, and extract commands. It would be better to refactor this into a shared helper function in core.rs to improve maintainability and ensure consistent behavior. Note that using io::Error::new with a specific io::ErrorKind is preferred over io::Error::other for better semantic clarity.

/// Maximum size of a link target in bytes.
/// Prevents memory exhaustion when reading link targets from untrusted archives.
pub(crate) const MAX_LINK_TARGET_SIZE: usize = 64 * 1024;

pub(crate) fn read_link_target<T: AsRef<[u8]>>(
    entry: &pna::NormalEntry<T>,
    password: Option<&[u8]>,
) -> std::io::Result<String> {
    let mut target = String::new();
    entry
        .reader(pna::ReadOptions::with_password(password))?
        .take(MAX_LINK_TARGET_SIZE as u64 + 1)
        .read_to_string(&mut target)?;
    if target.len() > MAX_LINK_TARGET_SIZE {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            "link target too long",
        ));
    }
    Ok(target)
}
References
  1. When reporting errors in Rust, using io::Error::new with a specific io::ErrorKind (e.g., io::ErrorKind::PermissionDenied) is preferred over io::Error::other with a generic message, as it provides better semantic clarity and aids in debugging and error handling.

Comment thread cli/src/command/list.rs
Comment on lines +7 to 10
MAX_LINK_TARGET_SIZE, PathFilter, ProcessAction, SplitArchiveReader,
TimeFilterResolver, TimeFilters, collect_split_archives, read_paths, run_read_entries,
run_read_entries_stoppable,
},
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.

medium

Add the read_link_target helper to the imports from core to allow refactoring the duplicated link target extraction logic.

            MAX_LINK_TARGET_SIZE, PathFilter, ProcessAction, SplitArchiveReader,
            TimeFilterResolver, TimeFilters, collect_split_archives, read_link_target,
            read_paths, run_read_entries, run_read_entries_stoppable,

Comment thread cli/src/command/list.rs
Comment on lines +433 to +447
(|| {
let mut reader = entry.reader(ReadOptions::with_password(password))?;
let mut s = String::new();
reader
.by_ref()
.take(MAX_LINK_TARGET_SIZE as u64 + 1)
.read_to_string(&mut s)?;
if s.len() > MAX_LINK_TARGET_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"link target too long",
));
}
Ok(s)
})()
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.

medium

Use the read_link_target helper function to simplify the code and avoid duplication.

                        read_link_target(entry, password)

Comment thread cli/src/command/list.rs
Comment on lines +457 to +471
(|| {
let mut reader = entry.reader(ReadOptions::with_password(password))?;
let mut s = String::new();
reader
.by_ref()
.take(MAX_LINK_TARGET_SIZE as u64 + 1)
.read_to_string(&mut s)?;
if s.len() > MAX_LINK_TARGET_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"link target too long",
));
}
Ok(s)
})()
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.

medium

Use the read_link_target helper function to simplify the code and avoid duplication.

                        read_link_target(entry, password)

Comment thread cli/src/command/diff.rs
command::{
Command, ask_password,
core::{SplitArchiveReader, collect_split_archives},
core::{MAX_LINK_TARGET_SIZE, SplitArchiveReader, collect_split_archives},
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.

medium

Add the read_link_target helper to the imports from core.

Suggested change
core::{MAX_LINK_TARGET_SIZE, SplitArchiveReader, collect_split_archives},
core::{MAX_LINK_TARGET_SIZE, SplitArchiveReader, collect_split_archives, read_link_target},

Comment thread cli/src/command/diff.rs
Comment on lines 309 to +320
let mut reader = entry.reader(ReadOptions::with_password(password))?;
let mut link_str = String::new();
reader.read_to_string(&mut link_str)?;
reader
.by_ref()
.take(MAX_LINK_TARGET_SIZE as u64 + 1)
.read_to_string(&mut link_str)?;
if link_str.len() > MAX_LINK_TARGET_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"link target too long",
));
}
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.

medium

Use the read_link_target helper function to simplify the code and avoid duplication.

Suggested change
let mut reader = entry.reader(ReadOptions::with_password(password))?;
let mut link_str = String::new();
reader.read_to_string(&mut link_str)?;
reader
.by_ref()
.take(MAX_LINK_TARGET_SIZE as u64 + 1)
.read_to_string(&mut link_str)?;
if link_str.len() > MAX_LINK_TARGET_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"link target too long",
));
}
let link_str = read_link_target(&entry, password)?;

Comment thread cli/src/command/diff.rs
Comment on lines 329 to +340
let mut reader = entry.reader(ReadOptions::with_password(password))?;
let mut target = String::new();
reader.read_to_string(&mut target)?;
reader
.by_ref()
.take(MAX_LINK_TARGET_SIZE as u64 + 1)
.read_to_string(&mut target)?;
if target.len() > MAX_LINK_TARGET_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"link target too long",
));
}
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.

medium

Use the read_link_target helper function to simplify the code and avoid duplication.

Suggested change
let mut reader = entry.reader(ReadOptions::with_password(password))?;
let mut target = String::new();
reader.read_to_string(&mut target)?;
reader
.by_ref()
.take(MAX_LINK_TARGET_SIZE as u64 + 1)
.read_to_string(&mut target)?;
if target.len() > MAX_LINK_TARGET_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"link target too long",
));
}
let target = read_link_target(&entry, password)?;

PermissionStrategyResolver, ProcessAction, SafeWriter, TimeFilterResolver, TimeFilters,
TimestampStrategy, TimestampStrategyResolver, Umask, XattrStrategy, apply_chroot,
collect_split_archives,
AclStrategy, FflagsStrategy, KeepOptions, MAX_LINK_TARGET_SIZE, MacMetadataStrategy,
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.

medium

Add the read_link_target helper to the imports from core.

Suggested change
AclStrategy, FflagsStrategy, KeepOptions, MAX_LINK_TARGET_SIZE, MacMetadataStrategy,
AclStrategy, FflagsStrategy, KeepOptions, MAX_LINK_TARGET_SIZE, read_link_target, MacMetadataStrategy,

Comment on lines +1426 to +1437
let mut reader = item.reader(ReadOptions::with_password(password))?;
let mut original = String::new();
reader
.by_ref()
.take(MAX_LINK_TARGET_SIZE as u64 + 1)
.read_to_string(&mut original)?;
if original.len() > MAX_LINK_TARGET_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"link target too long",
));
}
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.

medium

Use the read_link_target helper function to simplify the code and avoid duplication.

Suggested change
let mut reader = item.reader(ReadOptions::with_password(password))?;
let mut original = String::new();
reader
.by_ref()
.take(MAX_LINK_TARGET_SIZE as u64 + 1)
.read_to_string(&mut original)?;
if original.len() > MAX_LINK_TARGET_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"link target too long",
));
}
let original = read_link_target(&item, password)?;

Comment on lines +1452 to +1463
let mut reader = item.reader(ReadOptions::with_password(password))?;
let mut original = String::new();
reader
.by_ref()
.take(MAX_LINK_TARGET_SIZE as u64 + 1)
.read_to_string(&mut original)?;
if original.len() > MAX_LINK_TARGET_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"link target too long",
));
}
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.

medium

Use the read_link_target helper function to simplify the code and avoid duplication.

Suggested change
let mut reader = item.reader(ReadOptions::with_password(password))?;
let mut original = String::new();
reader
.by_ref()
.take(MAX_LINK_TARGET_SIZE as u64 + 1)
.read_to_string(&mut original)?;
if original.len() > MAX_LINK_TARGET_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"link target too long",
));
}
let original = read_link_target(&item, password)?;

@github-actions github-actions Bot added the cli This issue is about cli application label May 17, 2026
Define `MAX_LINK_TARGET_SIZE` (64 KiB) and enforce this limit when reading
symbolic and hard link targets from archives. This prevents a potential
denial-of-service (DoS) attack where a malicious archive contains
excessively large link targets that exhaust available memory during
extraction, listing, or comparison.

Changes:
- Added `MAX_LINK_TARGET_SIZE` constant in `cli/src/command/core.rs`.
- Enforced size limit in `cli/src/command/extract.rs`, `cli/src/command/list.rs`, and `cli/src/command/diff.rs`.
- Added integration tests in `cli/tests/cli/extract/link_limit.rs` to verify enforcement (excluded for WASM target).
- Suppressed unused variable warning in `cli/src/command/extract.rs` for non-macOS targets.
- Updated `.jules/sentinel.md` with security learnings.

Co-authored-by: ChanTsune <41658782+ChanTsune@users.noreply.github.com>
@ChanTsune ChanTsune closed this May 17, 2026
@ChanTsune ChanTsune deleted the sentinel/bound-link-target-size-6585411856353083135 branch May 17, 2026 04:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli This issue is about cli application

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant