Skip to content

fix(op): contain raw-file output paths inside the package directory - #1165

Merged
bryan-minimal merged 1 commit into
mainfrom
fix/raw-file-output-containment
Aug 3, 2026
Merged

fix(op): contain raw-file output paths inside the package directory#1165
bryan-minimal merged 1 commit into
mainfrom
fix/raw-file-output-containment

Conversation

@bryan-minimal

@bryan-minimal bryan-minimal commented Aug 3, 2026

Copy link
Copy Markdown
Member

Found by the audit that followed #1162. op::materialize::extract_raw_file had no containment check at all.

let rel_path = path.strip_prefix('/').unwrap_or(path);
let candidate = dir.path().join(rel_path);
if !candidate.exists() { continue; }
return deliver_file(&candidate, sink);

strip_prefix removes one leading slash and nothing else, so .. survives into the join and walks out of the cache dir; .exists() then greenlights it and deliver_file streams it back.

Impact

The path comes from [outputs.x] type = "raw-file" in minimal.toml, which the daemon reads out of a client-uploaded workspace — so it is attacker-influenceable:

[outputs.leak]
type = "raw-file"
path = "../../../etc/shadow"

That is a daemon-side arbitrary file read, delivered over the client its own channel. No symlink required. It also bypasses lcache::LocalDir entirely by taking the raw &Path from DirCacheEntry::path().

Fix

Two checks, because neither alone suffices:

  1. Lexical — normalize the request and reject an escape before any filesystem access. Covers ../../etc/passwd and /../../etc/passwd.
  2. Resolved — a package shipping escape -> /etc makes <pkg>/escape/passwd lexically contained while resolving outside, so containment is also decided on the canonicalized path.

A violation is an Err, deliberately not a continue: falling through to the next package would hide that a package tried to serve a file it does not own, and make the refusal indistinguishable from a cache miss.

common::archive::normalize_within_root is made public rather than growing a second implementation here — a second copy is exactly how this class got reintroduced before (#651). Its docs now state that it is lexical-only, so a caller touching the filesystem must also check the resolved path.

Verification

Three regression cases, all verified to fail without the fix — with the check neutralized the symlink case returns Report { bytes: 10 }, i.e. it really does deliver a file from outside the package.

cargo test -p op -p common green (44 + 19). Pre-existing sandbox2 clippy noise on macOS is unrelated (identical count on a clean tree; that code is Linux-only and cfg-d out here).

Independent of #1162 and #1164.

Note

Reject raw-file output paths that escape the package directory in extract_raw_file

  • Adds a lexical containment check in materialize.rs using common::archive::normalize_within_root to reject paths with .. components or absolute paths before joining.
  • Adds a runtime canonicalization check after locating the candidate file to reject symlinks that resolve outside the package directory.
  • Makes archive::normalize_within_root in archive.rs public so it can be reused cross-crate.
  • Adds tests for both lexical traversal and symlink-based escapes, verifying explicit error messages in each case.
  • Behavioral Change: paths that previously fell through as cache misses now return explicit errors when they escape the package directory.

Macroscope summarized 7557a55.

`extract_raw_file` had no containment check at all. It did

    let rel_path = path.strip_prefix('/').unwrap_or(path);
    let candidate = dir.path().join(rel_path);
    if !candidate.exists() { continue; }
    return deliver_file(&candidate, sink);

`strip_prefix('/')` removes one leading slash and nothing else, so `..`
survived untouched into the join and walked straight out of the cache
directory. `.exists()` then greenlit the result and `deliver_file` streamed
it back to the caller.

The path comes from `[outputs.x] type = "raw-file"` in `minimal.toml`, which
the daemon reads out of a client-uploaded workspace, so it is
attacker-influenceable: `path = "../../../etc/shadow"` is a daemon-side
arbitrary file read delivered over the client's own channel. No symlink
needed, and it bypasses `lcache::LocalDir`'s guards entirely by taking the
raw `&Path` from `DirCacheEntry::path()`.

Two checks, because either alone is insufficient:

- Lexically normalize the request and reject an escape before touching the
  filesystem. Covers both `../../etc/passwd` and `/../../etc/passwd`.
- Decide containment on the *resolved* path too: a package shipping
  `escape -> /etc` makes `<pkg>/escape/passwd` lexically contained while
  resolving outside it.

A containment violation is an `Err`, deliberately not a `continue`:
falling through to the next package would hide the fact that a package
tried to serve a file it does not own, and make the refusal indistinguishable
from a cache miss.

`common::archive::normalize_within_root` becomes public rather than gaining a
second implementation here — that is how this class of bug got reintroduced
before (#651). Its doc now says plainly that it is lexical, so a caller that
touches the filesystem must also check the resolved path.

Regression tests verified to fail without the fix; the symlink case delivers
10 bytes of a file outside the package when the check is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bryan-minimal
bryan-minimal requested a review from a team as a code owner August 3, 2026 19:55
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4d4b637f-6839-4f53-ab7e-00a1b5c5eb5f

📥 Commits

Reviewing files that changed from the base of the PR and between 3e1fcbd and 7557a55.

📒 Files selected for processing (2)
  • crates/common/src/archive.rs
  • crates/op/src/materialize.rs

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

@bryan-minimal
bryan-minimal merged commit 9f0bafa into main Aug 3, 2026
30 checks passed
@bryan-minimal
bryan-minimal deleted the fix/raw-file-output-containment branch August 3, 2026 20:36
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.

2 participants