Skip to content

Audit hardening: untrusted-file/IPC safety + WASAPI sample-rate fix#18

Merged
arizkami merged 5 commits into
mainfrom
fix/audit-input-hardening
Jun 19, 2026
Merged

Audit hardening: untrusted-file/IPC safety + WASAPI sample-rate fix#18
arizkami merged 5 commits into
mainfrom
fix/audit-input-hardening

Conversation

@arizkami

Copy link
Copy Markdown
Collaborator

Lead-engineer security/robustness sweep of all non-gpui crates (2026-06-19). Six findings, all fixed. Common theme: trust the real file/OS state, not attacker-controllable header/IPC values.

Fixes (one commit each, except #2/#4 share a file)

  • Re write/rust #1 WASAPI exclusive sample-rate desync (backend/wasapi_exclusive.rs) — exclusive mode passed the device mix format (native rate) to Initialize unchanged but stored the requested rate as the engine rate → hardware ran one rate while the engine believed another (silent pitch/tempo desync). Now stamps the requested rate into mix_fmt (nSamplesPerSec+nAvgBytesPerSec), probes IsFormatSupported, and falls back to native if unsupported; period/aligned-period derive from the chosen rate.
  • native: empty-default state, knob/fader port, file browser, shared menu dropdowns #2 WAV fmt chunk OOM (audio_file.rs) — read_wav_header allocated vec![0u8; len] from an untrusted 32-bit chunk length (up to 4 GiB) before reading. Reject lengths outside 16..=MAX_WAV_FMT_CHUNK_BYTES.
  • q #3 VST3/CLAP scan not isolated (scan/isolation.rs, scan/types.rs) — run_isolated_format_scan only isolated AudioUnit; VST3/CLAP loaded plugin binaries in-process (catch_unwind can't stop a C++ access violation). Now prefers the scanner subprocess for every format, in-process fallback only when the binary is missing. Added a format-generic ScannerProcessCrashed error.
  • audio: add native Rust facade on SphereDirectAudioEngine #4 WAV/RAUF peak/decode pre-alloc OOM (audio_file.rs) — LOD builders and load_rauf's vec![0u8; byte_len] trusted header frame counts (WAV data size, RAUF frames_written). Clamp to the bytes actually on disk.
  • Fix native browser timeline import flow #5 Shared-audio-bridge name squatting (audio_bridge.rs) — CreateFileMappingW opens an existing section (valid handle) on name collision, signalling only via ERROR_ALREADY_EXISTS, which the creator never checked. Now fails closed. (Ring indices were already masked/clamped, so this was DoS/correctness, not memory-unsafety.)
  • VST3 Support #6 IPC unbounded frame read (ipc/mod.rs) — read_frame used read_line with no ceiling. Cap each frame at MAX_FRAME_BYTES (128 MiB) via a take()-bounded read.

Tests

  • audio_file::peak_tests — absurd fmt length rejected; corrupt frames_written clamped.
  • ipc::tests — oversized frame rejected; within-limit frame round-trips.
  • audio_bridge::tests::create_named_rejects_squatted_name (Windows).

cargo check + cargo test --lib pass for both sphere-direct-audio-engine and sphere-plugin-host.

Not runtime-verified

  • Re write/rust #1 needs real hardware: set a sample rate different from the device's native rate in exclusive mode and confirm no pitch/tempo shift + correct fallback log.
  • q #3 needs the scanner binary + real plugins; verified by compile + logic only.

🤖 Generated with Claude Code

arizkami and others added 5 commits June 19, 2026 05:10
A crafted audio file could drive multi-gigabyte allocations during
import/probe, before a single sample is read:

- read_wav_header allocated `vec![0u8; len]` from the `fmt ` chunk's
  untrusted 32-bit length (up to 4 GiB). Reject lengths outside the
  16..=MAX_WAV_FMT_CHUNK_BYTES range before allocating.
- The peak-LOD builders and load_rauf's `vec![0u8; byte_len]` trusted
  header frame counts (WAV `data` chunk size, RAUF `frames_written`).
  Clamp them to the bytes actually present on disk in
  generate_wav_peaks_streaming, generate_rauf_peaks_streaming, and
  load_rauf.

The byte-slice path (wav_data_layout) already validated chunk bounds;
this brings the File-based paths to parity. Adds regression tests for
the absurd-fmt-length and corrupt-frames_written cases.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Exclusive mode passed the device mix format (native rate) to
IAudioClient::Initialize unchanged, but stored the *requested* rate as
the engine sample rate. A requested rate that differed from the device
native rate left the hardware running at one rate while the engine
believed another — silent pitch/tempo desync across transport, meters,
and recording.

Negotiate the real rate instead: stamp the requested rate into the mix
format (nSamplesPerSec + nAvgBytesPerSec), probe IsFormatSupported in
exclusive mode, and fall back to the native rate (with a log) when the
device rejects it. The reported sample_rate now always equals the rate
the hardware runs at, and both the initial and aligned-retry periods
derive from it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
run_isolated_format_scan only routed AudioUnit through the scanner
child; VST3/CLAP fell through to run_inprocess_scan, loading untrusted
plugin binaries directly in the host. catch_unwind can't stop a C++
access violation, so one malformed plugin crashed the app despite the
"isolated" name. (The app's bulk scan uses run_isolated_bundle_scan,
which was already isolated, but this public API was a footgun.)

Prefer the out-of-process scanner for every format when the scanner
binary is present; keep in-process as a best-effort fallback for builds
without it. Add a format-generic ScannerProcessCrashed error so a
crashed VST3/CLAP child no longer reports as "AudioUnit".

Co-Authored-By: Claude Opus 4.8 <[email protected]>
CreateFileMappingW opens the existing section (returning a valid handle)
when the named region already exists, signalling it only via
ERROR_ALREADY_EXISTS. The creator side never checked it, so a same-session
process that pre-created the predictably-named section would have the
engine map a region it didn't own. Ring indices are masked/clamped so
this was a DoS/correctness risk, not memory-unsafety — fail closed on the
creator side instead. Adds a Windows regression test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
read_frame used read_line with no ceiling, so a stuck or hostile peer
could grow the line buffer without limit via one newline-less giant line
(base64 plugin-state blobs travel this path). Cap each frame at
MAX_FRAME_BYTES via a take()-bounded read; split out read_frame_limited
so the bound is unit-testable without allocating the production limit.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@vercel

vercel Bot commented Jun 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
futureboard26 Error Error Jun 19, 2026 1:55am

@arizkami arizkami merged commit 8b0778c into main Jun 19, 2026
1 of 5 checks passed
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.

1 participant