The upload is attacker-supplied and its CONTENT can steer ffmpeg to open other files or URLs (HLS/concat playlists). Pins -protocol_whitelist file,pipe and -format_whitelist to the containers real recordings use, before the input (after -i it would apply to the output). Verified with the real static ffmpeg 7.0.2 on koala: real AAC .m4a and WAV still probe/convert; a crafted concat file is rejected with 'Format not on whitelist'. Note ffmpeg 7.0.2 ALREADY refused the playlists tried without the whitelist, so the live check cannot prove the whitelist -- the argv unit tests do; this protects against a different ffmpeg version being put in the image. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
110 lines
3.9 KiB
Go
110 lines
3.9 KiB
Go
// Package audio holds the adapters that inspect and convert user-supplied audio
|
|
// with ffmpeg's tools (ADR-033). They sit behind small ports so the use case is
|
|
// tested with fakes and only this package knows a subprocess is involved.
|
|
package audio
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.d-ma.be/mathias/tapir/internal/uploads"
|
|
)
|
|
|
|
// FFprobe implements uploads.Prober by running ffprobe on the spooled file.
|
|
type FFprobe struct {
|
|
// Bin is the ffprobe executable ("ffprobe" resolves via PATH).
|
|
Bin string
|
|
}
|
|
|
|
// AllowedDemuxers are the input formats an upload may be. The input is
|
|
// attacker-supplied, and a file's CONTENT can tell ffmpeg to open other files or
|
|
// URLs (HLS and concat playlists, image2 sequences, network demuxers). Recent
|
|
// ffmpeg refuses the ones tried (7.0.2: HLS without a .m3u8 name is not sniffed,
|
|
// concat rejects unsafe paths), but that is version luck; this list is not. It
|
|
// holds the containers real recordings use and none of the playlist or network
|
|
// formats. "mov" covers .mp4/.m4a/.3gp; "matroska" covers .webm.
|
|
const AllowedDemuxers = "wav,mov,mp4,m4a,3gp,mp3,ogg,matroska,webm,flac,aac,opus,aiff,amr,asf"
|
|
|
|
// inputRestrictions are ffmpeg/ffprobe INPUT options and must precede the input.
|
|
// Local files and pipes only: no network, no other protocols.
|
|
var inputRestrictions = []string{"-protocol_whitelist", "file,pipe", "-format_whitelist", AllowedDemuxers}
|
|
|
|
// killGrace bounds how long Probe waits for a cancelled ffprobe's pipes to close.
|
|
const killGrace = 2 * time.Second
|
|
|
|
// Probe runs ffprobe and classifies the outcome. A non-zero exit means ffprobe
|
|
// looked at the file and could not read it: that is the FILE's fault and returns
|
|
// uploads.ErrUnreadable. A missing binary, a cancelled context or unparsable
|
|
// output is OURS and is returned as an ordinary error, so it is never reported to
|
|
// the user as their file being bad.
|
|
func (f *FFprobe) Probe(ctx context.Context, path string) (uploads.Probe, error) {
|
|
bin := f.Bin
|
|
if bin == "" {
|
|
bin = "ffprobe"
|
|
}
|
|
args := append([]string{"-v", "error"}, inputRestrictions...)
|
|
args = append(args, "-print_format", "json", "-show_format", "-show_streams", path)
|
|
cmd := exec.CommandContext(ctx, bin, args...)
|
|
cmd.WaitDelay = killGrace
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return uploads.Probe{}, fmt.Errorf("audio: ffprobe: %w", ctxErr)
|
|
}
|
|
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("audio: run ffprobe: %w", err)
|
|
}
|
|
p, err := ParseProbe(out)
|
|
if err != nil {
|
|
return uploads.Probe{}, fmt.Errorf("audio: ffprobe output: %w", err)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// probeJSON is the subset of `ffprobe -print_format json` output that matters.
|
|
type probeJSON struct {
|
|
Streams []struct {
|
|
CodecType string `json:"codec_type"`
|
|
Disposition struct {
|
|
AttachedPic int `json:"attached_pic"`
|
|
} `json:"disposition"`
|
|
} `json:"streams"`
|
|
Format struct {
|
|
Duration string `json:"duration"`
|
|
} `json:"format"`
|
|
}
|
|
|
|
// ParseProbe turns ffprobe's JSON into a uploads.Probe. A video stream that is
|
|
// only attached cover art (an mp3/m4a with an album picture) is not video. A
|
|
// missing or "N/A" duration yields zero, which uploads.Validate rejects as
|
|
// unreadable rather than accepting an empty recording.
|
|
func ParseProbe(data []byte) (uploads.Probe, error) {
|
|
var j probeJSON
|
|
if err := json.Unmarshal(data, &j); err != nil {
|
|
return uploads.Probe{}, fmt.Errorf("decode: %w", err)
|
|
}
|
|
var p uploads.Probe
|
|
for _, s := range j.Streams {
|
|
switch s.CodecType {
|
|
case "audio":
|
|
p.HasAudio = true
|
|
case "video":
|
|
if s.Disposition.AttachedPic == 0 {
|
|
p.HasVideo = true
|
|
}
|
|
}
|
|
}
|
|
if secs, err := strconv.ParseFloat(j.Format.Duration, 64); err == nil && secs > 0 {
|
|
p.Duration = time.Duration(secs * float64(time.Second))
|
|
}
|
|
return p, nil
|
|
}
|