Skip to main content

Security

waxcut parses untrusted, attacker-controllable binary input (arbitrary MP3 files) with no external decoder in the loop, so the parser itself is the attack surface. Here's how that risk is managed, and how to report a problem.

Reporting a vulnerability

Please report security vulnerabilities privately using GitHub's private vulnerability reporting rather than filing a public issue.

You should receive an initial response within 14 days. If the report is confirmed, a fix will be prepared and a security advisory published once a patched release is available.

waxcut is pre-1.0, and only the latest released version is supported with security fixes — see SECURITY.md in the repository for the current policy.

Continuous fuzzing

The frame parser is fuzzed continuously with ClusterFuzzLite, via the cflite_pr.yml workflow. Every pull request is fuzzed against scan_frames — the only entry point that parses untrusted raw bytes directly — plus the downstream functions the harness calls on its result (total_duration_ms, frame_index_at, slice_bytes), with malformed and adversarial byte sequences: truncated headers, corrupted sync words, bogus bitrate/sample-rate indices, malformed ID3v2 tags — looking for crashes, hangs, or memory issues rather than correctness per se. This matters specifically because scan_frames reads raw, untrusted bytes directly (offsets and lengths all come from attacker-controlled header bits), including the leading ID3v2 tag it skips via id3v2_size. load_audio_stream — and the Xing/Info/VBRI/LAME-parsing code paths that only run inside it, not in scan_frames alone — is not exercised by this harness, nor are write_id3v2_tag or parse_cue_sheet.

Resource limits

Fuzzing (above) catches crashes and hangs on small mutated inputs within a CI time budget — it does not exercise deliberately large adversarial input, which is a different threat: a file packed with minimum-size MPEG2/2.5 Layer III frames (as little as ~24 bytes each) parses in linear time and never crashes, but produces one located frame per frame found.

This used to cost real memory amplification: AudioStream.frames was originally a list of individually-allocated Frame objects (~128 bytes each once you count the object itself plus its boxed int/float fields), so a 10 MB adversarial file built from the smallest legal Layer III frame this parser accepts (24 bytes) produced ~56 MB of Frame objects — roughly 5.3x amplification on top of the input bytes, scaling linearly with input size. AudioStream.frames is now backed by compact packed arrays instead (Frames, ~24 bytes/frame, unboxed), with individual Frame objects constructed lazily only when you actually index into or iterate the sequence. Re-measured on the same adversarial construction: the same 10 MB file now produces ~10.5 MB of Frames storage — amplification of roughly 1.0x, i.e. the parsed structure's size roughly tracks the input's, rather than multiplying it several times over. Both figures come from bench/security_claims.py (item 1) — run it yourself to reproduce them.

That removes amplification as a concern, but a single call still costs real, bounded time and memory proportional to input size — scan_frames and load_audio_stream both reject input over 250 MB by default, raising FileTooLargeError, so a single call's worst-case cost stays bounded regardless. A load_audio_stream call checks the file's size on disk before reading it, so an oversized file is never fully loaded into memory in the first place.

load_audio_stream(path, use_mmap=True) changes that calculus. Instead of reading the file into a Python bytes object, it memory-maps it, so the 250 MB default's memory-cost rationale doesn't apply — the file's bytes are never materialized in Python's heap in the first place, the OS pages them in on demand. What still applies is time: parsing is O(n) in file size no matter what backs the bytes, so a large enough mmap'd file still costs real wall-clock time to scan. use_mmap=True is therefore governed by its own, larger 2 GB limit, sized to bound that worst-case scan time rather than memory.

That worst case isn't a single number, though — it depends heavily on the input's shape. A file packed edge-to-edge with valid minimum-size frames scans at roughly 90 MB/s on this author's development machine (bench/security_claims.py, item 2 — this is a hardware-dependent timing, not a portable constant; re-run the script on your own machine before relying on it). A buffer that never forms a valid sync at all (e.g. a carpet of 0xFF bytes) is far slower per byte — around 5.5 MB/s in the same benchmark (item 3), since every byte forces a failed header-parse attempt instead of skipping a whole frame at once. At that rate, scanning the full 2 GB cap byte-by-byte would take several minutes, not ten seconds. scan_frames never actually gets there on adversarial input, though: _MAX_CONSECUTIVE_RESYNC_FAILURES aborts the scan after 2,000,000 consecutive failed resync attempts — about 360 ms in the same benchmark — long before byte count alone would force the issue. The 2 GB cap is safe against the adversarial case for that reason, not because the raw never-a-valid-sync scan rate is fast enough to finish in bounded time on its own — it isn't. The ~90 MB/s figure still matters for the valid-frame-carpet case (every failed resync attempt there is followed by a real frame, so the resync-count bound never trips), where it keeps a full 2 GB scan to well under a minute.

Because the file stays memory-mapped for as long as the AudioStream is alive, callers using use_mmap=True are responsible for calling AudioStream.close() (or using it as a context manager) when they're done with it — unlike the non-mmap path, where the file handle is closed once the bytes are read, the mmap'd file's handle stays open for the AudioStream's whole lifetime.

use_mmap=True is exercised in CI on Linux only — the ci.yml workflow runs exclusively on ubuntu-latest, so the mmap code path isn't independently verified on Windows or macOS. mmap's underlying semantics differ enough across platforms (page-alignment behavior, file-locking interaction, close-on-exec) that this is worth calling out explicitly rather than assuming portability.

Neither limit is currently a configurable parameter — if your use case legitimately needs to process larger files, please open an issue rather than relying on undocumented internals to work around it.

parse_cue_sheet (untrusted .cue file text) is a different case, and deliberately has no size cap. scan_frames/load_audio_stream read from a Path, so they need to reject an oversized file before ever reading it off disk into memory. parse_cue_sheet takes an already-materialized str -- by the time it's called, the caller has already paid the cost of holding that text in memory, so a cap inside parse_cue_sheet wouldn't bound anything the caller doesn't already control. This is a deliberate consequence of the two functions sitting at different I/O boundaries, not an oversight.

For completeness, the amplification from cue text to parsed timestamps is higher than the roughly-1.0x figure above for frame parsing — measured at roughly 3.8x: a 6.5 MB cue sheet producing 99,999 timestamps peaks around 24.3 MB (bench/security_claims.py, item 4). Robustness against malformed input has been checked by a set of hand-constructed edge cases (see tests/test_cue_sheet.py — malformed timestamps, out-of-range fields, missing INDEX 01, multi-FILE sheets, out-of-order timestamps, and more), each asserted to raise CueSheetError and nothing else. Unlike scan_frames, parse_cue_sheet isn't yet wired into the continuous ClusterFuzzLite harness described above — see Continuous fuzzing for exactly what that harness does and doesn't cover.

write_id3v2_tag's input guards

write_id3v2_tag is the one function in the public surface that writes text supplied by the caller — a title, artist, or track number — into binary tag frames, rather than only reading and validating bytes handed to it. That text isn't necessarily hand-typed: a realistic pipeline pulls it straight from parse_cue_sheet's TITLE/PERFORMER fields, which are themselves attacker-influenceable if the .cue file came from an untrusted source. Several guards keep that path bounded and unambiguous:

  • Size: the combined TIT2/TPE1/TRCK frame payload is capped by the ID3v2 tag format's own size field — a 4-byte syncsafe integer, whose maximum representable value is 2**28 - 1 (~256 MB). A pathologically large title (e.g. a caller accidentally passing an entire file's contents as title) raises ValueError rather than silently truncating or overflowing.
  • No stacked tags: if data already starts with an ID3v2 tag, write_id3v2_tag raises ValueError instead of prepending a second one. This isn't just a correctness fix — a stacked tag shifts where frame scanning actually starts, so scan_frames (which only skips one leading tag) would misinterpret real audio bytes as tag-adjacent data. That's a parser-confusion bug class, the same family fuzzing (above) exists to catch, even though this particular case is a deterministic input-validation guard rather than something fuzzing found.
  • No NUL/CR/LF in text fields: title/artist containing NUL, CR, or LF raise ValueError rather than passing through Latin-1/UTF-16 encoding unremarked. A NUL truncates the field for any reader that treats it as a C string terminator, and CR/LF can make stored content differ from what's displayed — rejecting them is safer than silently stripping, which could surprise a caller with different content than what they passed in.

Known limitation: write_id3v2_tag does not implement ID3v2 unsynchronisation (the spec-defined scheme that guarantees a false MPEG sync pattern can never occur inside a tag body, by inserting a 0x00 byte after every 0xFF byte and setting a flag telling compliant readers to undo that). A crafted title/artist could in principle produce a false sync word (e.g. 0xFF 0xFB) inside the written tag. waxcut itself is unaffected — it always skips the tag via id3v2_size before scanning for frames — but a non-compliant player that scans for sync words without first parsing the ID3v2 header could misdecode tag bytes as audio ahead of the real content. Tracked as a follow-up, not implemented yet.

Supply-chain and process posture

waxcut's security posture is checked and scored by two independent, automated programs:

  • OpenSSF Scorecard — evaluates the repository against a set of automated security health checks (branch protection, dependency pinning, CI configuration, and more) and publishes a score.
  • OpenSSF Best Practices — a self-assessed but publicly verifiable checklist covering the OpenSSF Best Practices Badge criteria (change control, quality, security); see the linked badge for the current status.

Ordinary CI — build, lint, and the full test suite, including the mutagen/ffmpeg cross-validation described in How It Works — runs on every pull request via the ci.yml workflow.