diff --git a/internal/adapters/audio/ffprobe.go b/internal/adapters/audio/ffprobe.go index aadf231..6a7e476 100644 --- a/internal/adapters/audio/ffprobe.go +++ b/internal/adapters/audio/ffprobe.go @@ -4,6 +4,7 @@ package audio import ( + "bytes" "context" "encoding/json" "errors" @@ -19,6 +20,45 @@ import ( type FFprobe struct { // Bin is the ffprobe executable ("ffprobe" resolves via PATH). Bin string + // MaxOutput bounds how much of ffprobe's stdout is read; zero means DefaultMaxOutput. + MaxOutput int +} + +// DefaultMaxOutput is far above the narrowed probe output of a legitimate file (a few +// hundred bytes) and far below what a hostile one can make ffprobe print. +const DefaultMaxOutput = 1 << 20 + +// maxStderr is how much of ffprobe's stderr is kept for the error message. +const maxStderr = 4096 + +// errOutputCap is the write error that stops ffprobe once it prints past the cap. +var errOutputCap = errors.New("audio: ffprobe output exceeds the cap") + +// capWriter collects up to max bytes and then refuses further writes, which closes the +// pipe and ends the process instead of buffering whatever it prints. +type capWriter struct { + buf bytes.Buffer + max int +} + +func (w *capWriter) Write(p []byte) (int, error) { + if w.buf.Len()+len(p) > w.max { + return 0, errOutputCap + } + return w.buf.Write(p) +} + +// headWriter keeps the first max bytes and silently drops the rest. +type headWriter struct { + buf bytes.Buffer + max int +} + +func (w *headWriter) Write(p []byte) (int, error) { + if room := w.max - w.buf.Len(); room > 0 { + w.buf.Write(p[:min(room, len(p))]) + } + return len(p), nil } // AllowedDemuxers are the input formats an upload may be. The input is @@ -64,18 +104,26 @@ func (f *FFprobe) Probe(ctx context.Context, path string) (uploads.Probe, error) args = append(args, "-print_format", "json", "-show_entries", probeEntries, path) cmd := exec.CommandContext(ctx, bin, args...) cmd.WaitDelay = killGrace - out, err := cmd.Output() - if err != nil { + maxOut := f.MaxOutput + if maxOut <= 0 { + maxOut = DefaultMaxOutput + } + stdout, stderr := &capWriter{max: maxOut}, &headWriter{max: maxStderr} + cmd.Stdout, cmd.Stderr = stdout, stderr + if err := cmd.Run(); err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return uploads.Probe{}, fmt.Errorf("audio: ffprobe: %w", ctxErr) } + if errors.Is(err, errOutputCap) { + return uploads.Probe{}, fmt.Errorf("%w: ffprobe printed more than %d bytes", uploads.ErrUnreadable, maxOut) + } var exit *exec.ExitError if errors.As(err, &exit) { - return uploads.Probe{}, fmt.Errorf("%w: ffprobe: %s", uploads.ErrUnreadable, exit.Stderr) + return uploads.Probe{}, fmt.Errorf("%w: ffprobe: %s", uploads.ErrUnreadable, stderr.buf.String()) } return uploads.Probe{}, fmt.Errorf("audio: run ffprobe: %w", err) } - p, err := ParseProbe(out) + p, err := ParseProbe(stdout.buf.Bytes()) if err != nil { return uploads.Probe{}, fmt.Errorf("audio: ffprobe output: %w", err) }