Wrong assumption
"A media tool's diagnostic output is small." internal/record already identified and fixed this assumption twice — stderrRetain (internal/record/proc.go:57-64) caps recorder stderr at 8 KiB with an explicit rationale that unbounded ffmpeg stderr "could grow by hundreds of MB … and OOM the parent", and probeSink (internal/record/recorders.go:162-168) caps the device listing at 1 MiB ("the cap only bounds a misbehaving binary"). internal/transcribe — the one package that hands attacker-authorable media to a media parser — applies no bound at any of its four subprocess sites, while consuming at most the last 800 bytes (tail(), internal/transcribe/transcribe.go:595-616) or a single JSON field.
Sites
| Site |
Call |
What is unbounded |
What is actually consumed |
internal/transcribe/ffmpeg.go:92-93 (convertRunner) |
cmd.CombinedOutput() |
ffmpeg stdout+stderr, default log level, no -loglevel/-nostats |
last 800 bytes, and only on error |
internal/transcribe/ffmpeg.go:211-212 (deriveOffset) |
cmd.Output() |
stdout: the full -show_format JSON, including verbatim metadata tag values (-v quiet governs stderr log level only; stderr is separately capped by the stdlib's 64 KiB prefix/suffix saver) |
one field, format.tags.creation_time |
internal/transcribe/whispercpp.go:57 (runWhisperCpp) |
cmd.CombinedOutput() |
whisper-cli's full stdout transcript + progress chatter |
last 800 bytes, only on error |
internal/transcribe/whisperx.go:68 (runWhisperX) |
cmd.CombinedOutput() |
whisperx's full stdout/stderr for the whole run |
last 800 bytes, only on error |
Go's CombinedOutput wires both pipes into one bare bytes.Buffer — no cap (verified against the go1.24 os/exec source).
Trigger
The inputs driving these children cross the trust boundaries this package's own comments defend: an external -audio recording ("a crafted input file's container metadata can reach it" — tail()'s comment; "ffprobe is a media parser, so pointing it at a file … widens its exposure" — checkExternalAudio), and a received session's audio.wav ("a session that was shared or downloaded rather than recorded here" — checkSessionAudio).
- A crafted or damaged container whose declared packet structure makes ffmpeg emit a per-packet warning produces stderr volume governed by packet count, not file size;
CombinedOutput accumulates it for the whole run — including a run that ends in exit 0, after which 100% of the buffer is discarded. Observed: parent memory grows with child chatter, up to OOM. Correct: retain a bounded tail, as record does for the identical stream.
ffprobe -show_format prints every format-level tag whole (JSON-escaped, so control bytes inflate several-fold); a crafted file carrying huge metadata makes deriveOffset buffer all of it to read one timestamp.
- On a successful CPU-only whisper run (hours long), the engine's entire stdout is buffered and then thrown away in the non-error path.
Aggravating detail from validation: transcribe's children are spawned with no SysProcAttr (only internal/record/record.go:498 sets Setpgid), so an OOM-killed parent leaves ffmpeg running and atomicConvert's defer os.Remove(tmpPath) never fires — a still-growing .audio-*.wav temp is left inside the session directory whose byte-for-byte integrity on a refused run transcribe.go defends at length.
Sibling sweep
The repo has exactly two subprocess-spawning packages. internal/record bounds both of its sinks (lockedBuffer at record.go:499-500, probeSink at recorders.go:212-214), each with written rationale; internal/transcribe bounds none of its four. .abcd/work/DECISIONS.md (round 41) noticed whisperx.go:68's CombinedOutput only as a progress-feedback usability observation, explicitly "no wrong behaviour, not actioned" — the memory question was never assessed, so this is an omission, not a recorded decision.
CWE
CWE-400 (uncontrolled resource consumption).
Validation
- Validator 1 (reachability lens): "The same 'a media tool's diagnostic output is small' assumption that
record bounded at 8 KiB with an explicit OOM rationale is left unbounded at all four transcribe subprocess sites, where ffmpeg's per-packet warnings and ffprobe's whole-metadata dump are driven by attacker-authored container structure the repo's own comments already treat as untrusted — and where at most 800 bytes of the buffer is ever read, and none at all on the success path."
- Validator 2 (correctness lens): "Transcribe spawns four subprocesses through
CombinedOutput/Output — a bytes.Buffer with no cap in Go's own implementation — while consuming at most an 800-byte tail or a single JSON field, leaving record's explicitly-reasoned stderrRetain/probeSink bounds (whose comments cite ffmpeg flooding and 'the cap only bounds a misbehaving binary') unapplied to the one package that hands attacker-authorable media to a media parser at default log level."
Severity is filed as minor: the failure is resource exhaustion of the transcribe command itself, not wrong output — atomicConvert renames only on success, so no artefact is corrupted.
Fix direction
Retain only a bounded tail (and, for ffprobe, a bounded head) of each child's output, as record's existing sinks already do.
Wrong assumption
"A media tool's diagnostic output is small."
internal/recordalready identified and fixed this assumption twice —stderrRetain(internal/record/proc.go:57-64) caps recorder stderr at 8 KiB with an explicit rationale that unbounded ffmpeg stderr "could grow by hundreds of MB … and OOM the parent", andprobeSink(internal/record/recorders.go:162-168) caps the device listing at 1 MiB ("the cap only bounds a misbehaving binary").internal/transcribe— the one package that hands attacker-authorable media to a media parser — applies no bound at any of its four subprocess sites, while consuming at most the last 800 bytes (tail(),internal/transcribe/transcribe.go:595-616) or a single JSON field.Sites
internal/transcribe/ffmpeg.go:92-93(convertRunner)cmd.CombinedOutput()-loglevel/-nostatsinternal/transcribe/ffmpeg.go:211-212(deriveOffset)cmd.Output()-show_formatJSON, including verbatim metadata tag values (-v quietgoverns stderr log level only; stderr is separately capped by the stdlib's 64 KiB prefix/suffix saver)format.tags.creation_timeinternal/transcribe/whispercpp.go:57(runWhisperCpp)cmd.CombinedOutput()internal/transcribe/whisperx.go:68(runWhisperX)cmd.CombinedOutput()Go's
CombinedOutputwires both pipes into one barebytes.Buffer— no cap (verified against the go1.24os/execsource).Trigger
The inputs driving these children cross the trust boundaries this package's own comments defend: an external
-audiorecording ("a crafted input file's container metadata can reach it" —tail()'s comment; "ffprobe is a media parser, so pointing it at a file … widens its exposure" —checkExternalAudio), and a received session'saudio.wav("a session that was shared or downloaded rather than recorded here" —checkSessionAudio).CombinedOutputaccumulates it for the whole run — including a run that ends in exit 0, after which 100% of the buffer is discarded. Observed: parent memory grows with child chatter, up to OOM. Correct: retain a bounded tail, asrecorddoes for the identical stream.ffprobe -show_formatprints every format-level tag whole (JSON-escaped, so control bytes inflate several-fold); a crafted file carrying huge metadata makesderiveOffsetbuffer all of it to read one timestamp.Aggravating detail from validation: transcribe's children are spawned with no
SysProcAttr(onlyinternal/record/record.go:498setsSetpgid), so an OOM-killed parent leaves ffmpeg running andatomicConvert'sdefer os.Remove(tmpPath)never fires — a still-growing.audio-*.wavtemp is left inside the session directory whose byte-for-byte integrity on a refused runtranscribe.godefends at length.Sibling sweep
The repo has exactly two subprocess-spawning packages.
internal/recordbounds both of its sinks (lockedBufferatrecord.go:499-500,probeSinkatrecorders.go:212-214), each with written rationale;internal/transcribebounds none of its four..abcd/work/DECISIONS.md(round 41) noticedwhisperx.go:68'sCombinedOutputonly as a progress-feedback usability observation, explicitly "no wrong behaviour, not actioned" — the memory question was never assessed, so this is an omission, not a recorded decision.CWE
CWE-400 (uncontrolled resource consumption).
Validation
recordbounded at 8 KiB with an explicit OOM rationale is left unbounded at all fourtranscribesubprocess sites, where ffmpeg's per-packet warnings and ffprobe's whole-metadata dump are driven by attacker-authored container structure the repo's own comments already treat as untrusted — and where at most 800 bytes of the buffer is ever read, and none at all on the success path."CombinedOutput/Output— abytes.Bufferwith no cap in Go's own implementation — while consuming at most an 800-byte tail or a single JSON field, leavingrecord's explicitly-reasonedstderrRetain/probeSinkbounds (whose comments cite ffmpeg flooding and 'the cap only bounds a misbehaving binary') unapplied to the one package that hands attacker-authorable media to a media parser at default log level."Severity is filed as minor: the failure is resource exhaustion of the transcribe command itself, not wrong output —
atomicConvertrenames only on success, so no artefact is corrupted.Fix direction
Retain only a bounded tail (and, for ffprobe, a bounded head) of each child's output, as
record's existing sinks already do.