Skip to content

Harden stem-id sanitizer against '..' and cap the extracted mix size - #4

Open
carochacs wants to merge 4 commits into
mainfrom
claude/security-issues-jdpj26
Open

Harden stem-id sanitizer against '..' and cap the extracted mix size#4
carochacs wants to merge 4 commits into
mainfrom
claude/security-issues-jdpj26

Conversation

@carochacs

@carochacs carochacs commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

What

Two LOW hardening items found during a security audit (follow-up to #2, merged):

1. Stem-id sanitizer allowed literal .. through. _sanitize()'s character-class allowlist includes ., so a literal ".." survives it untouched (only the other characters in a run, like /, get replaced). Not reachable today — the id is always derived from the separator engine's own output filename, never attacker/HTTP-supplied input — but a stray ".." reaching a later path-join would be a traversal primitive if that ever changes. Fixed by collapsing any run of 2+ dots to a single underscore as defense-in-depth.

2. No sanity cap on the extracted mix track. Nothing bounded the size of the mix audio extracted from a pak before handing it to any split engine (remote upload, or local audio-separator/demucs). The mix comes from the user's own already-owned library pak, not an untrusted network caller, so this isn't attacker-reachable — added a 1GB sanity cap as defense-in-depth against a malformed/corrupt pak.

Added regression tests: 3 for the sanitizer (tests/test_stem_ids.py) and 2 for the mix-size cap (tests/test_mix_size_cap.py, using a fake pak_io module injected via sys.modules since the real one pulls in the core sloppak lib not importable stand-alone here).

Verified: 140 passed (135 baseline + 5 new), zero regressions (4 pre-existing environment-only collection errors — missing httpx2 — unchanged before/after, excluded from this count the same way on both runs).

Checklist

  • No new user-facing behavior for legitimate stem ids or mix sizes
  • Regression tests added; existing test suite passes

Generated by Claude Code

Greptile Summary

The PR hardens fallback stem IDs by collapsing repeated dots and changes mix extraction to stream archive members before applying a 1 GiB sanity limit.

  • Adds sanitizer regression coverage for traversal-like dot sequences.
  • Adds streaming extraction and missing-member tests.
  • Adds tests for oversized and under-limit mix handling.

Confidence Score: 4/5

The PR does not yet appear safe to merge because the mix-size hardening still rejects valid large audio and does not bound resource use while extracting it.

The current HEAD retains both previously reported failures: the fixed threshold rejects every mix above 1 GiB regardless of validity, while copyfileobj can fill temporary storage with the entire decompressed member before that threshold is evaluated.

Files Needing Attention: pak_io.py and split_stems.py

Important Files Changed

Filename Overview
pak_io.py Streams mix members into temporary storage, but the copy remains unbounded until extraction completes.
split_stems.py Hardens sanitized stem IDs and adds a post-extraction 1 GiB limit that still rejects valid large mixes and cannot prevent extraction-time exhaustion.
tests/test_mix_size_cap.py Covers whether the fixed cap fires, but does not validate legitimate large inputs or enforce a bound during extraction.
tests/test_pak_io_extract_mix.py Confirms archive members are streamed and missing members raise FileNotFoundError.
tests/test_stem_ids.py Adds focused coverage showing repeated dots are removed while legitimate single dots remain supported.

Reviews (2): Last reviewed commit: "Stream pak mix extraction" | Re-trigger Greptile

Two LOW hardening items found during a security audit:

- _sanitize()'s character-class allowlist includes '.', so a literal
  ".." survives it untouched (only the OTHER characters in a run, like
  '/', get replaced). Not reachable today — the id is always derived
  from the separator engine's own output filename, never
  attacker/HTTP-supplied input — but a stray ".." reaching a later
  path-join would be a traversal primitive if that ever changes.
  Collapse any run of 2+ dots to a single underscore as defense-in-depth.

- No sanity cap on the mix track extracted from a pak before handing it
  to any split engine (remote upload, or local audio-separator/demucs).
  The mix comes from the user's own already-owned library pak, not an
  untrusted network caller, so this isn't attacker-reachable — it's a
  defense-in-depth bound against a malformed/corrupt pak.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018J1NvPtPQZd3cEt6aAbGzG
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

extract_mix now streams ZIP and directory inputs with optional size limits. split_pak applies a 4 GB extraction cap before separation. Stem sanitization collapses repeated dots. Regression tests cover extraction, size-cap wiring, and stem identifiers.

Changes

Mix processing safeguards

Layer / File(s) Summary
Stream mix extraction
pak_io.py, tests/test_pak_io_extract_mix.py
extract_mix adds MixTooLargeError and an optional max_bytes limit. ZIP members stream to disk, directory sources validate size before copying, and rejected outputs are removed.
Validate extracted mix size
split_stems.py, tests/test_mix_size_cap.py
split_pak passes a 4 GB limit to pak_io.extract_mix. Tests verify rejection before remote execution and allow under-cap mixes to continue.
Sanitize stem identifiers
split_stems.py, tests/test_stem_ids.py
_sanitize replaces runs of two or more dots with underscores. Tests cover traversal-like inputs and valid single dots.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant split_pak
  participant pak_io.extract_mix
  participant separation_engine
  split_pak->>pak_io.extract_mix: request extraction with 4 GB max_bytes
  pak_io.extract_mix-->>split_pak: return mix or MixTooLargeError
  split_pak->>separation_engine: start separation for accepted mix
Loading

Suggested reviewers: topkoa


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (1 error, 2 warnings)

Check name Status Explanation Resolution
Plugin.Json Version Bumped On Change ❌ Error The PR changes functional logic in pak_io.py and split_stems.py, but plugin.json is unchanged at version 0.6.1 from the base commit. Update plugin.json's version to a strictly greater valid semver value, such as 0.6.2, in the PR diff.
Plugin Folder Name Matches Manifest Id ⚠️ Warning plugin.json declares id "stem_splitter", but the standalone repository directory is "feedBack-plugin-stem-splitter"; the names differ in case, punctuation, and characters. Rename the standalone plugin directory/repository to exactly "stem_splitter", or change plugin.json id and all matching host references consistently.
Changelog Unreleased Section Updated ⚠️ Warning The PR changes pak extraction and stem sanitization, but its exact diff has no CHANGELOG.md change and the file has no [Unreleased] heading. Add a Keep a Changelog ## [Unreleased] section with a bullet describing the extraction cap and stem-ID sanitization changes.
✅ Passed checks (19 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes both main changes: hardening the stem-ID sanitizer and limiting extracted mix size.
Description check ✅ Passed The description directly explains the sanitizer and extraction hardening, implementation details, tests, and review findings.
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.
No Print()/Console.Log In Routes.Py ✅ Passed Added production lines in pak_io.py and split_stems.py contain no print(...) or traceback.print_exc(...); routes.py and setup(app, context) are unchanged.
Sibling Imports Use Load_sibling ✅ Passed The PR adds no sibling imports to runtime plugin modules; pak_io.py and split_stems.py have no import changes. New direct imports occur only in tests.
Routes Namespaced Under /Api/Plugins/Id ✅ Passed The PR adds no route registrations: origin/main...HEAD changes only pak_io.py, split_stems.py, and tests; routes.py and plugin.json are unchanged.
Blocking Route Handlers Use Def Not Async ✅ Passed No routes.py changes occur in the PR range; the only async handlers are pre-existing, so this check has no new or modified async handler to reject.
No Per-Frame Dom Queries In Draw/Raf ✅ Passed The full PR diff modifies no .js files, so it introduces no per-frame DOM queries or new MutationObserver usage in modified JavaScript.
Shortcuts Unregistered With Matching Scope ✅ Passed The diff adds no window.registerShortcut call; repository searches found no registerShortcut, unregisterShortcut, or shortcut-panel API usage requiring teardown.
Idempotent Guard On Top-Level Listeners ✅ Passed The PR-range diff contains no changes to screen.js, so it adds no top-level listener, interval, or playSong/showScreen wrapper requiring guard review.
Server_files Entries Are Safe Relpaths ✅ Passed The base-to-HEAD diff contains no plugin.json changes, and plugin.json has no settings.server_files or diagnostics.server_files entries to validate.
Setrenderer Factory Has Init/Draw/Destroy ✅ Passed The PR changes Python extraction and stem-sanitization code only; no window.feedBackViz_ factory or renderer resource acquisition appears in the diff or repository.
Overlay Gates On Isdefaultrenderer Instructions ✅ Passed The diff adds no overlay drawing code and contains no highway.project, highway.fretX, isDefaultRenderer, or requestAnimationFrame references.
V3 Ui Mounts Via Playercontrolslot ✅ Passed The PR changes only Python extraction/sanitization code and tests; no new #player-controls lookup or DOM injection exists, so no v3 mount branch is required.
New Feedpak Manifest Keys Declared In Spec ✅ Passed The PR diff adds no new feedpak manifest key. It changes mix extraction and stem sanitization; existing keys such as stems, original_audio, and stem_separation are unchanged.
Feedpak Manifest Required Keys Present ✅ Passed The cumulative PR diff does not touch manifest assembly. It changes mix extraction, sanitizer logic, and cap wiring; no title, artist, duration, or arrangements[] emission becomes conditional or is...
New Python Modules Have Pytest Coverage ✅ Passed The hardening diff adds no production Python module; pak_io.py and split_stems.py are modifications. Added tests reference extract_mix, MixTooLargeError, split_pak, and _sanitize.
No Hardcoded Secrets Or Tokens In Diff ✅ Passed Scanned all 333 added lines: no AWS AKIA keys, PEM private-key headers, credential-like assignments, or credential-related literals were found.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/security-issues-jdpj26

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

Comment thread split_stems.py Outdated
Comment thread split_stems.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 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 `@pak_io.py`:
- Around line 97-114: Update extract_mix to accept the maximum allowed size and
enforce it during both ZIP and directory-pak extraction. Validate regular source
files before copying, stream/count bytes without writing beyond the limit, and
delete any partial output before raising when the cap is exceeded; update
split_pak to pass the size limit into extract_mix.

In `@split_stems.py`:
- Around line 66-73: The comment above _sanitize incorrectly claims current
callers do not derive input from attacker/HTTP data. Update only that
trust-boundary explanation to acknowledge that _run_remote sanitizes name from
the split-server HTTP response before writing the stem file, while retaining the
existing dot-run sanitization.

In `@tests/test_mix_size_cap.py`:
- Around line 50-63: Update test_mix_under_cap_proceeds_past_the_check to mock
ss._run_remote with a known sentinel RuntimeError, then assert that exact
exception is raised and _run_remote is called once. Remove the live
remote-server dependency while preserving the assertion that the exception does
not contain “sanity limit”.
🪄 Autofix

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 Plus

Run ID: 685542e5-3731-4eb8-861a-61125902a9d6

📥 Commits

Reviewing files that changed from the base of the PR and between ee58fbd and 505a21f.

📒 Files selected for processing (5)
  • pak_io.py
  • split_stems.py
  • tests/test_mix_size_cap.py
  • tests/test_pak_io_extract_mix.py
  • tests/test_stem_ids.py

Comment thread pak_io.py
Comment thread split_stems.py
Comment thread tests/test_mix_size_cap.py Outdated
Greptile review on this PR, both valid:

1. The fixed 1GB threshold rejected legitimately large mixes (a long
   or high-res-master full mix can plausibly exceed that). Raised to
   4GB — generous for a ~45min stereo mix at 32-bit float/192kHz
   (~4GB) while still bounding a runaway decompression.

2. More importantly: split_pak()'s cap check ran AFTER
   pak_io.extract_mix() had already fully decompressed and written
   the mix to disk via copyfileobj — so a real decompression bomb
   would still consume all that disk/CPU before being rejected,
   defeating the point of a size cap entirely.

Moved enforcement into extract_mix() itself via a new `max_bytes`
parameter (default None, fully backward compatible for any other
caller): the zip-form path checks the archive's own declared
info.file_size up front — reading zip central-directory metadata, no
decompression needed — before ever opening the entry, and also tracks
the running total during the chunked streaming copy as defense in
depth. The directory-form path checks stat().st_size before copying.
Either way rejection happens before/during the write, and any partial
output is removed. New MixTooLargeError (a RuntimeError subclass, so
existing broad exception handling still works).

Verified experimentally: a 50MB-of-zeros bomb (~51KB compressed)
against a 1KB cap is now rejected in ~2ms, based purely on declared
size — never decompressed, no file left in the output directory.

Rewrote tests/test_mix_size_cap.py to test what it actually should
now: that split_pak() wires _MAX_MIX_BYTES through to extract_mix()
as max_bytes (the enforcement itself moved to pak_io and is tested in
tests/test_pak_io_extract_mix.py, which gained 4 new cases: the real
bomb-shape rejection, an under-cap positive case, the directory-form
equivalent, and a no-cap-passed backward-compatibility check). All
new tests confirmed to fail against the pre-fix code and pass after.

Verified: full suite 147 passed (142 baseline + 5 net new), zero
regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018J1NvPtPQZd3cEt6aAbGzG

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

carochacs has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

…m tests

Two follow-up review comments (CodeRabbit) on this PR:

1. _sanitize()'s comment claimed "nothing currently derives this from
   attacker/HTTP input," which is false: _run_remote's `name` comes
   directly from the split server's JSON response ("stems" dict keys),
   a few lines above the exact comment that already documents that
   response as attacker-choosable via a malicious/compromised server.
   The sanitizer's actual safety property is unaffected (result_dir /
   f"{_sanitize(name)}{ext}" can never produce a literal ".." since an
   extension is always appended, and '/' is stripped regardless of the
   dot-collapse fix) — this was a documentation-accuracy fix, not a
   behavior change.

2. Two tests let split_pak() reach _run_remote for real against
   "http://split.invalid", relying on DNS failing fast and asserting
   only a bare `Exception` — flaky under network policies where that
   doesn't fail fast, and too broad to distinguish "cap correctly not
   tripped" from an unrelated failure. Mocked _run_remote directly
   with a sentinel RuntimeError instead: no live I/O, and the assertion
   now checks for that exact sentinel plus a single call, which is
   only reachable if the cap check already passed.

Verified: full suite still 147 passed, zero regressions; the two
fixed tests now run in ~0.02s instead of depending on network timing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018J1NvPtPQZd3cEt6aAbGzG

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

carochacs has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

@carochacs I will review the changes in pull request #4.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
pak_io.py (1)

146-151: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Keep the directory-form copy bounded after the metadata check.

The stat() check occurs before shutil.copyfile reopens the source at Line 152. A source file can grow or be replaced between these operations. The copy can then exceed max_bytes and exhaust temporary storage.

Use the same bounded read/write loop for directory-form extraction. Delete out if the running total exceeds the limit. Add a regression test that changes the source after the metadata check.

🤖 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 `@pak_io.py` around lines 146 - 151, The directory-form extraction path around
the metadata size check and subsequent shutil.copyfile must use a bounded
read/write loop that tracks copied bytes, deletes out when the running total
exceeds max_bytes, and raises MixTooLargeError. Add a regression test that
modifies or replaces the source after the stat check to verify oversized copies
are stopped and temporary output is removed.
🧹 Nitpick comments (1)
tests/test_pak_io_extract_mix.py (1)

72-75: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that declared-size rejection does not open the ZIP member.

The current test verifies only the final exception and output state. An implementation could inflate the member before raising and still pass. Patch zipfile.ZipFile.open to fail in this test. extract_mix must raise MixTooLargeError before it opens the member.

Proposed test change
-            with self.assertRaises(pak_io.MixTooLargeError):
-                pak_io.extract_mix(
-                    pak, {"original_audio": "stems/full.ogg"}, out_dir, max_bytes=1024,
-                )
+            with mock.patch.object(
+                zipfile.ZipFile,
+                "open",
+                side_effect=AssertionError("oversized member must not be opened"),
+            ):
+                with self.assertRaises(pak_io.MixTooLargeError):
+                    pak_io.extract_mix(
+                        pak, {"original_audio": "stems/full.ogg"}, out_dir, max_bytes=1024,
+                    )
🤖 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_pak_io_extract_mix.py` around lines 72 - 75, Update the test
around pak_io.extract_mix to patch zipfile.ZipFile.open so any attempt to open
the ZIP member fails, while retaining the MixTooLargeError assertion. Verify the
declared-size limit is rejected before the member is opened and preserve the
existing output-state checks.
🤖 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 `@pak_io.py`:
- Around line 117-133: The broad OSError handler around extraction incorrectly
converts destination I/O failures on out, including open, write, close, or
unlink operations, into FileNotFoundError. Narrow the exception handling in the
relevant pak extraction function so only archive opening and member lookup
errors receive the missing-mix translation, while destination I/O errors
propagate unchanged and cleanup does not leave a partial file.

---

Duplicate comments:
In `@pak_io.py`:
- Around line 146-151: The directory-form extraction path around the metadata
size check and subsequent shutil.copyfile must use a bounded read/write loop
that tracks copied bytes, deletes out when the running total exceeds max_bytes,
and raises MixTooLargeError. Add a regression test that modifies or replaces the
source after the stat check to verify oversized copies are stopped and temporary
output is removed.

---

Nitpick comments:
In `@tests/test_pak_io_extract_mix.py`:
- Around line 72-75: Update the test around pak_io.extract_mix to patch
zipfile.ZipFile.open so any attempt to open the ZIP member fails, while
retaining the MixTooLargeError assertion. Verify the declared-size limit is
rejected before the member is opened and preserve the existing output-state
checks.
🪄 Autofix

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 Plus

Run ID: bb42187b-b5f0-4f37-95d6-4ef1837fac8a

📥 Commits

Reviewing files that changed from the base of the PR and between 505a21f and c911d48.

📒 Files selected for processing (4)
  • pak_io.py
  • split_stems.py
  • tests/test_mix_size_cap.py
  • tests/test_pak_io_extract_mix.py

Comment thread pak_io.py
Comment on lines +117 to +133
with zf.open(info, "r") as src, out.open("wb") as dst:
written = 0
while True:
chunk = src.read(1024 * 1024)
if not chunk:
break
written += len(chunk)
if max_bytes is not None and written > max_bytes:
dst.close()
out.unlink(missing_ok=True)
raise MixTooLargeError(
f"mix track exceeded the {max_bytes} byte sanity limit "
f"while extracting (declared size did not match actual "
f"decompressed size) — refusing to extract what looks "
f"like a corrupt pak"
)
dst.write(chunk)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Do not translate destination I/O errors to FileNotFoundError.

Lines 117-133 execute inside the except OSError handler below. If opening, writing, closing, or deleting out fails, the function reports that the mix is missing. It can also leave a partial file. Restrict that translation to archive and member lookup errors. Let destination I/O errors propagate with their actual cause.

🤖 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 `@pak_io.py` around lines 117 - 133, The broad OSError handler around
extraction incorrectly converts destination I/O failures on out, including open,
write, close, or unlink operations, into FileNotFoundError. Narrow the exception
handling in the relevant pak extraction function so only archive opening and
member lookup errors receive the missing-mix translation, while destination I/O
errors propagate unchanged and cleanup does not leave a partial file.

@carochacs carochacs self-assigned this Aug 7, 2026
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