diff --git a/internal/api/admin_auth_test.go b/internal/api/admin_auth_test.go new file mode 100644 index 000000000..708f88060 --- /dev/null +++ b/internal/api/admin_auth_test.go @@ -0,0 +1,45 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAdminAuthAlwaysRequired(t *testing.T) { + for _, endpoint := range []string{"config", "restart", "exit"} { + for _, method := range []string{"GET", "POST", "PATCH", "OPTIONS"} { + for _, remote := range []string{"127.0.0.1:1234", "[::1]:1234", "192.0.2.1:1234", "@"} { + for _, credentials := range []struct { + user, pass string + want int + }{ + {"", "", 401}, {"admin", "wrong", 401}, {"admin", "secret", 204}, + } { + handler := requireAdmin("admin", "secret", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }) + req := httptest.NewRequest(method, "/go2rtc/api/"+endpoint, nil) + req.RemoteAddr = remote + if credentials.user != "" { + req.SetBasicAuth(credentials.user, credentials.pass) + } + res := httptest.NewRecorder() + handler(res, req) + require.Equal(t, credentials.want, res.Code, "%s %s %s", endpoint, method, remote) + } + } + } + } +} + +func TestAdminDisabledWithoutCredentials(t *testing.T) { + for _, credentials := range [][2]string{{"", ""}, {"admin", ""}, {"", "secret"}} { + handler := requireAdmin(credentials[0], credentials[1], func(http.ResponseWriter, *http.Request) { t.Fatal("admin handler reached") }) + req := httptest.NewRequest("PATCH", "/api/config", nil) + req.SetBasicAuth(credentials[0], credentials[1]) + res := httptest.NewRecorder() + handler(res, req) + require.Equal(t, 403, res.Code) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 5edf31f4d..c510d8379 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -39,7 +39,7 @@ func Init() { } // default config - cfg.Mod.Listen = ":1984" + cfg.Mod.Listen = "127.0.0.1:1984" // load config from YAML app.LoadConfig(&cfg) @@ -58,9 +58,9 @@ func Init() { initStatic(cfg.Mod.StaticDir) HandleFunc("api", apiHandler) - HandleFunc("api/config", configHandler) - HandleFunc("api/exit", exitHandler) - HandleFunc("api/restart", restartHandler) + HandleFunc("api/config", requireAdmin(cfg.Mod.Username, cfg.Mod.Password, configHandler)) + HandleFunc("api/exit", requireAdmin(cfg.Mod.Username, cfg.Mod.Password, exitHandler)) + HandleFunc("api/restart", requireAdmin(cfg.Mod.Username, cfg.Mod.Password, restartHandler)) HandleFunc("api/log", logHandler) Handler = http.DefaultServeMux // 4th @@ -357,3 +357,21 @@ func Error(w http.ResponseWriter, err error) { http.Error(w, err.Error(), http.StatusInsufficientStorage) } + +// Administrative endpoints never inherit the loopback/OPTIONS auth exemption. +// With no credentials configured, they are disabled rather than public. +func requireAdmin(username, password string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if username == "" || password == "" { + http.Error(w, "Administrative API disabled: configure credentials", http.StatusForbidden) + return + } + user, pass, ok := r.BasicAuth() + if !ok || user != username || pass != password { + w.Header().Set("WWW-Authenticate", `Basic realm="go2rtc"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next(w, r) + } +} diff --git a/internal/exec/exec.go b/internal/exec/exec.go index e428aefbe..35d8965b6 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -67,21 +67,33 @@ func execHandle(rawURL string) (prod core.Producer, err error) { rawURL, rawQuery, _ := strings.Cut(rawURL, "#") query := streams.ParseQuery(rawQuery) - var path string + return newProducer(rawURL, shell.QuoteSplit(rawURL[5:]), query) +} - // RTSP flow should have `{output}` inside URL - // pipe flow may have `#{params}` inside URL - if i := strings.Index(rawURL, "{output}"); i > 0 { +// NewProducer runs an argument vector built by a trusted module. It is not a +// stream scheme and cannot be reached through an exec: redirect. +func NewProducer(source string, args []string) (core.Producer, error) { + return newProducer(source, args, nil) +} + +func newProducer(source string, args []string, query url.Values) (prod core.Producer, err error) { + if len(args) == 0 { + return nil, errors.New("exec: empty command") + } + args = slices.Clone(args) + var path string + for i, arg := range args { + if arg != "{output}" { + continue + } if rtsp.Port == "" { return nil, errors.New("exec: rtsp module disabled") } - - sum := md5.Sum([]byte(rawURL)) + sum := md5.Sum([]byte(source)) path = "/" + hex.EncodeToString(sum[:]) - rawURL = rawURL[:i] + "rtsp://127.0.0.1:" + rtsp.Port + path + rawURL[i+8:] + args[i] = "rtsp://127.0.0.1:" + rtsp.Port + path } - - cmd := shell.NewCommand(rawURL[5:]) // remove `exec:` + cmd := shell.NewCommandArgs(args) cmd.Stderr = &logWriter{ buf: make([]byte, 512), debug: log.Debug().Enabled(), @@ -116,9 +128,9 @@ func execHandle(rawURL string) (prod core.Producer, err error) { } if path == "" { - prod, err = handlePipe(rawURL, cmd) + prod, err = handlePipe(source, cmd) } else { - prod, err = handleRTSP(rawURL, cmd, path, timeout) + prod, err = handleRTSP(source, cmd, path, timeout) } if err != nil { diff --git a/internal/ffmpeg/README.md b/internal/ffmpeg/README.md index 03f29215a..998c2de40 100644 --- a/internal/ffmpeg/README.md +++ b/internal/ffmpeg/README.md @@ -49,6 +49,7 @@ ffmpeg: - You can use `video` and `audio` params multiple times (ex. `#video=copy#audio=copy#audio=pcmu`) - You can use `rotate` param with `90`, `180`, `270` or `-90` values, important with transcoding (ex. `#video=h264#rotate=90`) - You can use `width` and/or `height` params, important with transcoding (ex. `#video=h264#width=1280`) +- You can use `framerate` with HTTP JPEG/MJPEG inputs that don't provide timestamps (ex. `#framerate=5#video=h264`) - You can use `drawtext` to add a timestamp (ex. `drawtext=x=2:y=2:fontsize=12:fontcolor=white:box=1:boxcolor=black`) - This will greatly increase the CPU of the server, even with hardware acceleration - You can use `timeout` param to set RTSP input timeout in seconds (ex. `#timeout=10`) diff --git a/internal/ffmpeg/ffmpeg.go b/internal/ffmpeg/ffmpeg.go index a3d589b16..7d9d4edb8 100644 --- a/internal/ffmpeg/ffmpeg.go +++ b/internal/ffmpeg/ffmpeg.go @@ -1,11 +1,16 @@ package ffmpeg import ( + "errors" + "math" "net/url" + "regexp" + "strconv" "strings" "github.com/AlexxIT/go2rtc/internal/api" "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/exec" "github.com/AlexxIT/go2rtc/internal/ffmpeg/device" "github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware" "github.com/AlexxIT/go2rtc/internal/ffmpeg/virtual" @@ -16,6 +21,10 @@ import ( "github.com/rs/zerolog" ) +func init() { + streams.HandleValidator("ffmpeg", func(source string) error { return validateSource(source[7:]) }) +} + func Init() { var cfg struct { Mod map[string]string `yaml:"ffmpeg"` @@ -38,18 +47,7 @@ func Init() { } defaults["global"] += " -v " + cfg.Log.Level - streams.RedirectFunc("ffmpeg", func(url string) (string, error) { - if _, err := Version(); err != nil { - return "", err - } - args := parseArgs(url[7:]) - if core.Contains(args.Codecs, "auto") { - return "", nil // force call streams.HandleFunc("ffmpeg") - } - return "exec:" + args.String(), nil - }) - - streams.HandleFunc("ffmpeg", NewProducer) + streams.HandleFunc("ffmpeg", streamProducer) api.HandleFunc("api/ffmpeg", apiFFmpeg) @@ -179,7 +177,23 @@ func inputTemplate(name, s string, query url.Values) string { return strings.Replace(template, "{input}", s, 1) } +func validFrameRate(s string) bool { + if strings.Count(s, "/") == 1 { + parts := strings.SplitN(s, "/", 2) + num, errNum := strconv.ParseUint(parts[0], 10, 64) + den, errDen := strconv.ParseUint(parts[1], 10, 64) + return errNum == nil && errDen == nil && num > 0 && den > 0 + } + + rate, err := strconv.ParseFloat(s, 64) + return err == nil && rate > 0 && !math.IsInf(rate, 0) && !math.IsNaN(rate) +} + func parseArgs(s string) *ffmpeg.Args { + return parseArgsMode(s, false) +} + +func parseArgsMode(s string, streamOnly bool) *ffmpeg.Args { // init FFmpeg arguments args := &ffmpeg.Args{ Bin: defaults["bin"], @@ -204,7 +218,15 @@ func parseArgs(s string) *ffmpeg.Args { if i := strings.Index(s, "://"); i > 0 { switch s[:i] { case "http", "https", "rtmp": - args.Input = inputTemplate("http", s, query) + args.Input = inputTemplate("http", "{input}", query) + args.InputValue = s + // HTTP JPEG/MJPEG streams often don't carry timestamps. FFmpeg then + // assumes 25 fps, which compresses the output timeline when frames + // actually arrive at a lower rate. Input-side -r generates timestamps + // at the configured rate before the video is transcoded. + if frameRate := query.Get("framerate"); validFrameRate(frameRate) { + args.Input = "-r " + frameRate + " " + args.Input + } case "rtsp", "rtsps": // https://ffmpeg.org/ffmpeg-protocols.html#rtsp // skip unnecessary input tracks @@ -217,11 +239,13 @@ func parseArgs(s string) *ffmpeg.Args { args.Input = "-allowed_media_types audio " } - args.Input += inputTemplate("rtsp", s, query) + args.Input += inputTemplate("rtsp", "{input}", query) + args.InputValue = s default: - args.Input = "-i " + s + args.Input = "-i {input}" + args.InputValue = s } - } else if streams.Get(s) != nil { + } else if (streamOnly && streamName.MatchString(s)) || streams.Get(s) != nil { s = "rtsp://127.0.0.1:" + rtsp.Port + "/" + s switch { case args.Video > 0 && args.Audio == 0: @@ -235,7 +259,8 @@ func parseArgs(s string) *ffmpeg.Args { for _, v := range query["query"] { s += "&" + v } - args.Input = inputTemplate("rtsp", s, query) + args.Input = inputTemplate("rtsp", "{input}", query) + args.InputValue = s } else if i = strings.Index(s, "?"); i > 0 { switch s[:i] { case "device": @@ -246,7 +271,8 @@ func parseArgs(s string) *ffmpeg.Args { args.Input = virtual.GetInputTTS(s[i+1:]) } } else { - args.Input = inputTemplate("file", s, query) + args.Input = inputTemplate("file", "{input}", query) + args.InputValue = s } if query["async"] != nil { @@ -386,3 +412,121 @@ func parseArgs(s string) *ffmpeg.Args { return args } + +func streamProducer(source string) (core.Producer, error) { + if err := streams.Validate(source); err != nil { + return nil, err + } + if _, err := Version(); err != nil { + return nil, err + } + // Treat bare names as RTSP references even if registration changes while + // building the command. They must never fall back to local file input. + args := parseArgsMode(source[7:], true) + if core.Contains(args.Codecs, "auto") { + return NewProducer(source) + } + return exec.NewProducer(source, args.Argv()) +} + +var streamName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + +var positiveNumber = regexp.MustCompile(`^[0-9]+$`) +var bitrateValue = regexp.MustCompile(`^[0-9]+[kKmM]?$`) + +// API and config sources may select known presets, not supply command fragments +// or arbitrary FFmpeg protocols (which can read/write local files). +func validateSource(source string) error { + input, params, _ := strings.Cut(source, "#") + if strings.HasPrefix(input, "virtual?") { + if err := validateVirtual(strings.TrimPrefix(input, "virtual?")); err != nil { + return err + } + } else if !streamName.MatchString(input) { + u, err := url.Parse(input) + if err != nil || u.Host == "" { + return errors.New("ffmpeg: expected network URL or stream name") + } + switch u.Scheme { + case "http", "https", "rtsp", "rtsps", "rtmp": + default: + return errors.New("ffmpeg: unsupported input protocol") + } + } + for key, values := range streams.ParseQuery(params) { + for _, value := range values { + valid := false + switch key { + case "video", "audio": + prefix := "-c:v " + if key == "audio" { + prefix = "-c:a " + } + valid = value == "copy" || (key == "audio" && value == "auto") || + strings.HasPrefix(defaults[value], prefix) || + strings.HasPrefix(defaults[value], strings.Replace(prefix, "-c:", "-codec:", 1)) + case "input": + valid = value == "http" || value == "rtsp" || value == "rtsp/udp" + case "width", "height", "timeout": + valid = positiveNumber.MatchString(value) + case "bitrate": + valid = bitrateValue.MatchString(value) + case "framerate": + valid = validFrameRate(value) + case "rotate": + valid = value == "90" || value == "180" || value == "270" || value == "-90" + case "hardware": + valid = value == "" || value == "software" || value == "vaapi" || value == "cuda" || value == "dxva2" || value == "videotoolbox" || value == "rkmpp" || value == "v4l2m2m" + case "async": + valid = value == "" || value == "1" + } + if !valid { + return errors.New("ffmpeg: unsupported source parameter: " + key) + } + } + } + return nil +} + +// Synthetic test patterns remain available, but arbitrary lavfi graphs and +// percent-encoded command fragments are not accepted as virtual inputs. +func validateVirtual(rawQuery string) error { + query, err := url.ParseQuery(rawQuery) + if err != nil { + return err + } + if len(query["video"]) != 1 { + return errors.New("ffmpeg: expected one virtual video pattern") + } + for key, values := range query { + if len(values) != 1 { + return errors.New("ffmpeg: duplicate virtual parameter") + } + value := values[0] + valid := false + switch key { + case "video": + valid = value == "" || value == "testsrc" || value == "testsrc2" || value == "smptebars" || value == "color" || value == "mandelbrot" + case "size": + valid = virtualSize.MatchString(value) || value == "720" || value == "1080" || value == "2K" || value == "4K" || value == "8K" + case "color": + valid = virtualColor.MatchString(value) + case "rate", "duration": + valid = validFrameRate(value) + case "sar": + valid = virtualRatio.MatchString(value) + case "decimals": + valid = positiveNumber.MatchString(value) + case "format": + valid = value == "yuv420p" || value == "yuv422p" || value == "yuv444p" || value == "rgb24" || value == "gray" + } + if !valid { + return errors.New("ffmpeg: unsupported virtual parameter: " + key) + } + } + return nil +} + +var virtualSize = regexp.MustCompile(`^[0-9]+x[0-9]+$`) +var virtualColor = regexp.MustCompile(`^[A-Za-z]+$|^0x[0-9A-Fa-f]{6}$`) +var virtualRatio = regexp.MustCompile(`^[0-9]+/[0-9]+$`) diff --git a/internal/ffmpeg/ffmpeg_test.go b/internal/ffmpeg/ffmpeg_test.go index b9d021839..fe1cc72f7 100644 --- a/internal/ffmpeg/ffmpeg_test.go +++ b/internal/ffmpeg/ffmpeg_test.go @@ -98,6 +98,21 @@ func TestParseArgsIpCam(t *testing.T) { source: "http://example.com#video=h264", expect: `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, }, + { + name: "[HTTP-MJPEG] input framerate generates the correct timestamps", + source: "http://example.com#framerate=5#video=h264", + expect: `ffmpeg -hide_banner -r 5 -fflags nobuffer -flags low_delay -i http://example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[HTTP-MJPEG] rational input framerate is supported", + source: "http://example.com#framerate=30000/1001#video=h264", + expect: `ffmpeg -hide_banner -r 30000/1001 -fflags nobuffer -flags low_delay -i http://example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, + { + name: "[HTTP-MJPEG] invalid input framerate is ignored", + source: "http://example.com#framerate=5%20-an#video=h264", + expect: `ffmpeg -hide_banner -fflags nobuffer -flags low_delay -i http://example.com -c:v libx264 -g 50 -profile:v high -level:v 4.1 -preset:v superfast -tune:v zerolatency -pix_fmt:v yuv420p -an -user_agent ffmpeg/go2rtc -rtsp_transport tcp -f rtsp {output}`, + }, { name: "[HLS] video will be copied, audio will be skipped", source: "https://example.com#video=copy", diff --git a/internal/ffmpeg/jpeg.go b/internal/ffmpeg/jpeg.go index 63d886dd6..40723b513 100644 --- a/internal/ffmpeg/jpeg.go +++ b/internal/ffmpeg/jpeg.go @@ -9,22 +9,20 @@ import ( "github.com/AlexxIT/go2rtc/internal/ffmpeg/hardware" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/ffmpeg" - "github.com/AlexxIT/go2rtc/pkg/shell" ) func JPEGWithQuery(b []byte, query url.Values) ([]byte, error) { args := parseQuery(query) - return transcode(b, args.String()) + return transcode(b, args.Argv()) } func JPEGWithScale(b []byte, width, height int) ([]byte, error) { args := defaultArgs() args.AddFilter(fmt.Sprintf("scale=%d:%d", width, height)) - return transcode(b, args.String()) + return transcode(b, args.Argv()) } -func transcode(b []byte, args string) ([]byte, error) { - cmdArgs := shell.QuoteSplit(args) +func transcode(b []byte, cmdArgs []string) ([]byte, error) { cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) cmd.Stdin = bytes.NewBuffer(b) return cmd.Output() diff --git a/internal/ffmpeg/security_test.go b/internal/ffmpeg/security_test.go new file mode 100644 index 000000000..afbdea28c --- /dev/null +++ b/internal/ffmpeg/security_test.go @@ -0,0 +1,81 @@ +package ffmpeg + +import ( + "testing" + + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/shell" + "github.com/stretchr/testify/require" +) + +func TestFFmpegSourceRejectsCommandFragments(t *testing.T) { + for _, source := range []string{ + "virtual?video=movie%3D%2Fetc%2Fpasswd", "virtual?video=color&color=red%20-i%20file:///etc/passwd", + "virtual?video=color&size=1x1,amovie=/etc/passwd", "virtual?video=testsrc&format=rgb24,movie=/etc/passwd", + "virtual?video=testsrc&video=movie", "file:///etc/passwd", "/etc/passwd", "concat:http://camera/a|file:///etc/passwd", "device?video=0", + "rtsp://camera/live#raw=-i", "rtsp://camera/live#input=-i", "rtsp://camera/live#video=-y", "rtsp://camera/live#audio=-y", + "rtsp://camera/live#timeout=1 -i /etc/passwd", "rtsp://camera/live#bitrate=1 -y", "rtsp://camera/live#width=1,movie=/etc/passwd", + "rtsp://camera/live#drawtext=textfile=/etc/passwd", "rtsp://camera/live#query=arbitrary", "rtsp://camera/live#hardware=-i", + } { + require.Error(t, validateSource(source), source) + } + for _, source := range []string{ + "virtual?video=testsrc&size=640x480&rate=5#video=h264", "virtual?video=color&color=blue&size=720#video=h264", + "rtsp://camera/live#video=h264#audio=opus#input=rtsp/udp", + "http://camera/video#video=h265#width=1280#height=720#bitrate=2M#framerate=30000/1001", + "rtsp://camera/live#audio=auto", "rtsp://camera/live#video=copy#hardware=vaapi", + } { + require.NoError(t, validateSource(source), source) + } +} + +func TestFFmpegArgvPreservesInputBoundary(t *testing.T) { + source := `rtsp://camera/" -i file:///tmp/injected -y "` + args := parseArgs(source + "#video=h264") + argv := args.Argv() + inputs := 0 + for i, arg := range argv { + if arg == "-i" { + inputs++ + require.Equal(t, source, argv[i+1]) + } + require.NotEqual(t, "-y", arg) + } + require.Equal(t, 1, inputs) +} + +func TestFFmpegArgvMatchesSupportedTemplates(t *testing.T) { + for _, source := range []string{ + "rtsp://camera/live#video=h264#audio=opus", + "rtsp://camera/live#video=h265#width=1280#height=720", + "http://camera/video#video=mjpeg#framerate=5", + "rtsp://camera/live#audio=pcmu", "rtsp://camera/live#video=h264#video=h265#audio=aac#audio=opus", + "rtsp://camera/live#video=h264#hardware=vaapi", + } { + args := parseArgs(source) + require.Equal(t, shell.QuoteSplit(args.String()), args.Argv(), source) + } +} + +func TestFFmpegValidationAppliesBeforeRegistration(t *testing.T) { + _, err := streams.New("unsafe", "ffmpeg:virtual?video=movie%3D%2Fetc%2Fpasswd#video=mjpeg") + require.Error(t, err) + require.Nil(t, streams.Get("unsafe")) +} + +func TestFFmpegMissingStreamCannotBecomeLocalFile(t *testing.T) { + require.NoError(t, streams.Validate("ffmpeg:passwd#video=h264")) + args := parseArgsMode("passwd#video=h264", true).Argv() + for i, arg := range args { + if arg == "-i" { + require.Contains(t, args[i+1], "rtsp://127.0.0.1:") + } + } +} + +func TestFFmpegAcceptsLightNVRCodecTemplates(t *testing.T) { + saved := defaults["h264"] + t.Cleanup(func() { defaults["h264"] = saved }) + defaults["h264"] = "-codec:v libx264 -g:v 30 -preset:v superfast" + require.NoError(t, streams.Validate("ffmpeg:virtual?video=color&size=64x64#video=h264")) +} diff --git a/internal/streams/handlers.go b/internal/streams/handlers.go index ab4e8cf8b..f3e915484 100644 --- a/internal/streams/handlers.go +++ b/internal/streams/handlers.go @@ -48,6 +48,17 @@ func HasProducer(url string) bool { } func GetProducer(url string) (core.Producer, error) { + return getProducer(url, 0) +} + +func getProducer(url string, depth int) (core.Producer, error) { + if depth >= 10 { + return nil, errors.New("streams: too many redirects") + } + // Every dispatch, including redirect results, crosses the same boundary. + if err := Validate(url); err != nil { + return nil, err + } if i := strings.IndexByte(url, ':'); i > 0 { scheme := url[:i] @@ -57,7 +68,7 @@ func GetProducer(url string) (core.Producer, error) { return nil, err } if location != "" { - return GetProducer(location) + return getProducer(location, depth+1) } } @@ -112,12 +123,21 @@ func GetConsumer(url string) (core.Consumer, func(), error) { return nil, nil, errors.New("streams: unsupported scheme: " + url) } -var insecure = map[string]bool{} +// Config streams are loaded before module Init functions register handlers. +var insecure = map[string]bool{"exec": true, "echo": true, "expr": true} func MarkInsecure(scheme string) { insecure[scheme] = true } +// Validators are registered during Go package initialization, before config +// loading or API listeners can accept sources. +var validators = map[string]func(string) error{} + +func HandleValidator(scheme string, validate func(string) error) { + validators[scheme] = validate +} + var sanitize = regexp.MustCompile(`\s`) func Validate(source string) error { @@ -138,8 +158,13 @@ func Validate(source string) error { if sanitize.MatchString(source) { return errors.New("streams: source with spaces may be insecure") } - if strings.IndexAny(source, `'"`) >= 0 { + if strings.IndexAny(source, "\x00"+`'"`) >= 0 { return errors.New("streams: source with shell quotes may be insecure") } + if i := strings.IndexByte(source, ':'); i > 0 { + if validate := validators[source[:i]]; validate != nil { + return validate(source) + } + } return nil } diff --git a/internal/streams/handlers_test.go b/internal/streams/handlers_test.go index bbcd41d00..c7de0145c 100644 --- a/internal/streams/handlers_test.go +++ b/internal/streams/handlers_test.go @@ -21,7 +21,7 @@ func TestValidateAllowsCommonFFmpegParams(t *testing.T) { require.NoError(t, err) } -func TestNewStreamAllowsTrustedConfigRawFFmpeg(t *testing.T) { - stream := NewStream(`ffmpeg:rtsp://127.0.0.1:8554/testsrc#raw="-i""file:///tmp/config_input"`) - require.Equal(t, []string{`ffmpeg:rtsp://127.0.0.1:8554/testsrc#raw="-i""file:///tmp/config_input"`}, stream.Sources()) +func TestConfigRejectsRawFFmpeg(t *testing.T) { + _, err := configStream(`ffmpeg:rtsp://127.0.0.1:8554/testsrc#raw="-i""file:///tmp/config_input"`) + require.Error(t, err) } diff --git a/internal/streams/security_test.go b/internal/streams/security_test.go new file mode 100644 index 000000000..0b7ea78e9 --- /dev/null +++ b/internal/streams/security_test.go @@ -0,0 +1,53 @@ +package streams + +import ( + "testing" + + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/stretchr/testify/require" +) + +func TestProducerRejectsUnsafeRedirects(t *testing.T) { + oldHandlers, oldRedirects := handlers, redirects + t.Cleanup(func() { handlers, redirects = oldHandlers, oldRedirects }) + called := false + handlers = map[string]Handler{"exec": func(string) (core.Producer, error) { called = true; return nil, nil }} + for _, target := range []string{"exec:sh", "echo:unsafe", "expr:unsafe", `ffmpeg:rtsp://camera/live#raw=-i`, `rtsp://camera/"quoted"`} { + redirects = map[string]Redirect{"test": func(string) (string, error) { return target, nil }} + _, err := GetProducer("test:source") + require.Error(t, err, target) + } + require.False(t, called) +} + +func TestProducerSafeRedirectAndCycle(t *testing.T) { + oldHandlers, oldRedirects := handlers, redirects + t.Cleanup(func() { handlers, redirects = oldHandlers, oldRedirects }) + called := false + handlers = map[string]Handler{"safe": func(string) (core.Producer, error) { called = true; return nil, nil }} + redirects = map[string]Redirect{"test": func(string) (string, error) { return "safe:camera", nil }} + _, err := GetProducer("test:source") + require.NoError(t, err) + require.True(t, called) + redirects["test"] = func(string) (string, error) { return "test:cycle", nil } + _, err = GetProducer("test:source") + require.ErrorContains(t, err, "too many redirects") +} + +func TestConfigStreamRejectsUnsafeSources(t *testing.T) { + for _, source := range []any{ + "exec:sh", []string{"rtsp://camera/live", "exec:sh"}, + []any{"rtsp://camera/live", "echo:unsafe"}, + map[string]any{"url": []any{"expr:unsafe"}}, + `ffmpeg:rtsp://camera/live#raw=-i`, []any{42}, 42, + } { + stream, err := configStream(source) + require.Error(t, err, "%v", source) + require.Nil(t, stream) + } + for _, source := range []any{"rtsp://camera/live", []any{"rtsp://camera/live", nil}, map[string]any{"url": "rtsp://camera/live"}, nil} { + stream, err := configStream(source) + require.NoError(t, err) + require.NotNil(t, stream) + } +} diff --git a/internal/streams/streams.go b/internal/streams/streams.go index 53b150e96..374131f22 100644 --- a/internal/streams/streams.go +++ b/internal/streams/streams.go @@ -23,7 +23,12 @@ func Init() { log = app.GetLogger("streams") for name, item := range cfg.Streams { - streams[name] = NewStream(item) + stream, err := configStream(item) + if err != nil { + log.Error().Err(err).Str("stream", name).Msg("[streams] rejected config source") + continue + } + streams[name] = stream } api.HandleFunc("api/streams", apiStreams) @@ -175,3 +180,38 @@ func GetAllSources() map[string][]string { streamsMu.Unlock() return sources } + +// Validate all supported YAML stream forms before constructing producers. +func configStream(item any) (*Stream, error) { + switch value := item.(type) { + case string: + if err := Validate(value); err != nil { + return nil, err + } + case []string: + for _, source := range value { + if err := Validate(source); err != nil { + return nil, err + } + } + case []any: + for _, source := range value { + if source == nil { + continue + } + text, ok := source.(string) + if !ok { + return nil, errors.New("streams: expected string source") + } + if err := Validate(text); err != nil { + return nil, err + } + } + case map[string]any: + return configStream(value["url"]) + case nil: + default: + return nil, errors.New("streams: invalid config source") + } + return NewStream(item), nil +} diff --git a/pkg/ffmpeg/ffmpeg.go b/pkg/ffmpeg/ffmpeg.go index a7ca71c73..3a8a0c88c 100644 --- a/pkg/ffmpeg/ffmpeg.go +++ b/pkg/ffmpeg/ffmpeg.go @@ -4,6 +4,8 @@ import ( "bytes" "strconv" "strings" + + "github.com/AlexxIT/go2rtc/pkg/shell" ) // correlation of libavformat versions with ffmpeg versions @@ -16,13 +18,14 @@ const ( ) type Args struct { - Bin string // ffmpeg - Global string // -hide_banner -v error - Input string // -re -stream_loop -1 -i /media/bunny.mp4 - Codecs []string // -c:v libx264 -g:v 30 -preset:v ultrafast -tune:v zerolatency - Filters []string // scale=1920:1080 - Output string // -f rtsp {output} - Version string // libavformat version, it's more reliable than the ffmpeg version + InputValue string // substituted only after tokenizing trusted input templates + Bin string // ffmpeg + Global string // -hide_banner -v error + Input string // -re -stream_loop -1 -i /media/bunny.mp4 + Codecs []string // -c:v libx264 -g:v 30 -preset:v ultrafast -tune:v zerolatency + Filters []string // scale=1920:1080 + Output string // -f rtsp {output} + Version string // libavformat version, it's more reliable than the ffmpeg version Video, Audio int // count of Video and Audio params } @@ -67,7 +70,7 @@ func (a *Args) String() string { if strings.HasPrefix(a.Input, "-re ") && a.Version >= Version61 { b.WriteString("-readrate_initial_burst 0.001 ") } - b.WriteString(a.Input) + b.WriteString(strings.ReplaceAll(a.Input, "{input}", a.InputValue)) multimode := a.Video > 1 || a.Audio > 1 var iv, ia int @@ -121,3 +124,35 @@ func ParseVersion(b []byte) (ffmpeg string, libavformat string) { } return } + +// Argv preserves data argument boundaries; filter expressions and input URLs +// are never assembled into a command string and split again. +func (a *Args) Argv() []string { + args := []string{a.Bin} + args = append(args, shell.QuoteSplit(a.Global)...) + if strings.HasPrefix(a.Input, "-re ") && a.Version >= Version61 { + args = append(args, "-readrate_initial_burst", "0.001") + } + for _, arg := range shell.QuoteSplit(a.Input) { + args = append(args, strings.ReplaceAll(arg, "{input}", a.InputValue)) + } + multimode := a.Video > 1 || a.Audio > 1 + var iv, ia int + for _, codec := range a.Codecs { + if multimode && len(codec) >= 5 { + switch codec[:5] { + case "-c:v ": + codec = "-map 0:v:0? " + strings.ReplaceAll(codec, ":v ", ":v:"+strconv.Itoa(iv)+" ") + iv++ + case "-c:a ": + codec = "-map 0:a:0? " + strings.ReplaceAll(codec, ":a ", ":a:"+strconv.Itoa(ia)+" ") + ia++ + } + } + args = append(args, shell.QuoteSplit(codec)...) + } + if len(a.Filters) > 0 { + args = append(args, "-vf", strings.Join(a.Filters, ",")) + } + return append(args, shell.QuoteSplit(a.Output)...) +} diff --git a/pkg/ffmpeg/security_test.go b/pkg/ffmpeg/security_test.go new file mode 100644 index 000000000..cf16aef8a --- /dev/null +++ b/pkg/ffmpeg/security_test.go @@ -0,0 +1,12 @@ +package ffmpeg + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +func TestArgvKeepsFilterAsOneArgument(t *testing.T) { + filter := `drawtext=text=" -i /tmp/injected -y "` + args := &Args{Bin: "/path with spaces/ffmpeg", Input: "-i {input}", InputValue: `http://camera/" -y "`, Filters: []string{filter}, Output: "-f mjpeg -"} + require.Equal(t, []string{"/path with spaces/ffmpeg", "-i", `http://camera/" -y "`, "-vf", filter, "-f", "mjpeg", "-"}, args.Argv()) +} diff --git a/pkg/shell/command.go b/pkg/shell/command.go index b7c818995..ee6ef6934 100644 --- a/pkg/shell/command.go +++ b/pkg/shell/command.go @@ -17,8 +17,11 @@ type Command struct { } func NewCommand(s string) *Command { + return NewCommandArgs(QuoteSplit(s)) +} + +func NewCommandArgs(args []string) *Command { ctx, cancel := context.WithCancel(context.Background()) - args := QuoteSplit(s) cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd.SysProcAttr = procAttr return &Command{cmd, ctx, cancel, nil}