prevent directory traversal attacks (#5602)

Path names are used as part of paths in several components: in the
recorder, in the playback server and in every HTTP-based component
(WebRTC, HLS, API). Special characters that allow to escape from the
intended directory are now forbidden in order to prevent directory
traversal attacks.
This commit is contained in:
Alessandro Ros
2026-03-23 20:16:12 +01:00
committed by GitHub
parent d5d1de0bd2
commit 7418e51031
10 changed files with 265 additions and 31 deletions
-22
View File
@@ -16,7 +16,6 @@ import (
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/bluenviron/mediamtx/internal/logger"
"github.com/bluenviron/mediamtx/internal/protocols/httpp"
"github.com/bluenviron/mediamtx/internal/recordstore"
)
func interfaceIsEmpty(i any) bool {
@@ -44,27 +43,6 @@ func paramName(ctx *gin.Context) (string, bool) {
return name[1:], true
}
func recordingsOfPath(
pathConf *conf.Path,
pathName string,
) *defs.APIRecording {
ret := &defs.APIRecording{
Name: pathName,
}
segments, _ := recordstore.FindSegments(pathConf, pathName, nil, nil)
ret.Segments = make([]defs.APIRecordingSegment, len(segments))
for i, seg := range segments {
ret.Segments[i] = defs.APIRecordingSegment{
Start: seg.Start,
}
}
return ret
}
type apiAuthManager interface {
Authenticate(req *auth.Request) (string, *auth.Error)
RefreshJWTJWKS()
+21
View File
@@ -13,6 +13,27 @@ import (
"github.com/gin-gonic/gin"
)
func recordingsOfPath(
pathConf *conf.Path,
pathName string,
) *defs.APIRecording {
ret := &defs.APIRecording{
Name: pathName,
}
segments, _ := recordstore.FindSegments(pathConf, pathName, nil, nil)
ret.Segments = make([]defs.APIRecordingSegment, len(segments))
for i, seg := range segments {
ret.Segments[i] = defs.APIRecordingSegment{
Start: seg.Start,
}
}
return ret
}
func (a *API) onRecordingsList(ctx *gin.Context) {
a.mutex.RLock()
c := a.Conf
+101 -4
View File
@@ -156,10 +156,11 @@ func TestRecordingsDeleteSegment(t *testing.T) {
require.NoError(t, err)
defer api.Close()
err = os.Mkdir(filepath.Join(dir, "mypath1"), 0o755)
err = os.MkdirAll(filepath.Join(dir, "group", "cam1"), 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "mypath1", "2008-11-07_11-22-00-900000.mp4"), []byte(""), 0o644)
segmentPath := filepath.Join(dir, "group", "cam1", "2008-11-07_11-22-00-900000.mp4")
err = os.WriteFile(segmentPath, []byte(""), 0o644)
require.NoError(t, err)
tr := &http.Transport{}
@@ -170,9 +171,105 @@ func TestRecordingsDeleteSegment(t *testing.T) {
require.NoError(t, err)
v := url.Values{}
v.Set("path", "mypath1")
v.Set("path", "group/cam1")
v.Set("start", time.Date(2008, 11, 7, 11, 22, 0, 900000000, time.Local).Format(time.RFC3339Nano))
u.RawQuery = v.Encode()
httpRequest(t, hc, http.MethodDelete, u.String(), nil, nil)
req, err := http.NewRequest(http.MethodDelete, u.String(), nil)
require.NoError(t, err)
resp, err := hc.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
_, err = os.Stat(segmentPath)
require.Error(t, err)
require.True(t, os.IsNotExist(err))
}
func TestRecordingsDeleteSegmentInvalidPath(t *testing.T) {
dir, err := os.MkdirTemp("", "mediamtx-playback")
require.NoError(t, err)
defer os.RemoveAll(dir)
cnf := tempConf(t, "pathDefaults:\n"+
" recordPath: "+filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f")+"\n"+
"paths:\n"+
" all_others:\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.Duration(10 * time.Second),
WriteTimeout: conf.Duration(10 * time.Second),
Conf: cnf,
AuthManager: test.NilAuthManager,
Parent: &testParent{},
}
err = api.Initialize()
require.NoError(t, err)
defer api.Close()
err = os.MkdirAll(filepath.Join(dir, "group", "cam1"), 0o755)
require.NoError(t, err)
segmentPath := filepath.Join(dir, "group", "cam1", "2008-11-07_11-22-00-900000.mp4")
err = os.WriteFile(segmentPath, []byte(""), 0o644)
require.NoError(t, err)
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
u, err := url.Parse("http://localhost:9997/v3/recordings/deletesegment")
require.NoError(t, err)
v := url.Values{}
v.Set("path", "group/../cam1")
v.Set("start", time.Date(2008, 11, 7, 11, 22, 0, 900000000, time.Local).Format(time.RFC3339Nano))
u.RawQuery = v.Encode()
req, err := http.NewRequest(http.MethodDelete, u.String(), nil)
require.NoError(t, err)
resp, err := hc.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
_, err = os.Stat(segmentPath)
require.NoError(t, err)
}
func TestRecordingsSegmentGetInvalidPath(t *testing.T) {
dir, err := os.MkdirTemp("", "mediamtx-playback")
require.NoError(t, err)
defer os.RemoveAll(dir)
cnf := tempConf(t, "pathDefaults:\n"+
" recordPath: "+filepath.Join(dir, "%path/%Y-%m-%d_%H-%M-%S-%f")+"\n"+
"paths:\n"+
" all_others:\n")
api := API{
Address: "localhost:9997",
ReadTimeout: conf.Duration(10 * time.Second),
WriteTimeout: conf.Duration(10 * time.Second),
Conf: cnf,
AuthManager: test.NilAuthManager,
Parent: &testParent{},
}
err = api.Initialize()
require.NoError(t, err)
defer api.Close()
tr := &http.Transport{}
defer tr.CloseIdleConnections()
hc := &http.Client{Transport: tr}
resp, err := hc.Get("http://localhost:9997/v3/recordings/get/group/../cam1")
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
+14 -5
View File
@@ -19,7 +19,7 @@ import (
"github.com/bluenviron/mediamtx/internal/logger"
)
var rePathName = regexp.MustCompile(`^[0-9a-zA-Z_\-/\.~:]+$`)
var rePathName = regexp.MustCompile(`^[0-9a-zA-Z_\-/\.]+$`)
// IsValidPathName checks whether the path name is valid.
func IsValidPathName(name string) error {
@@ -36,7 +36,14 @@ func IsValidPathName(name string) error {
}
if !rePathName.MatchString(name) {
return fmt.Errorf("can contain only alphanumeric characters, underscore, dot, tilde, minus, slash, colon")
return fmt.Errorf("can contain only alphanumeric characters, underscore, dot, minus, slash")
}
// prevent directory traversal attacks
for segment := range strings.SplitSeq(name, "/") {
if segment == "." || segment == ".." {
return fmt.Errorf("can't contain dot path segments")
}
}
return nil
@@ -128,17 +135,19 @@ func checkAlwaysAvailableFile(fpath string) error {
// FindPathConf returns the configuration corresponding to the given path name.
func FindPathConf(pathConfs map[string]*Path, name string) (*Path, []string, error) {
// normal path
// static path configuration
if pathConf, ok := pathConfs[name]; ok {
return pathConf, nil, nil
}
// regexp path configuration
err := IsValidPathName(name)
if err != nil {
return nil, nil, fmt.Errorf("invalid path name: %w (%s)", err, name)
}
// gather and sort all regexp-based path configs
// gather and sort all regexp path configs
var regexpPathConfs []*Path
for _, pathConf := range pathConfs {
if pathConf.Regexp != nil {
@@ -156,7 +165,7 @@ func FindPathConf(pathConfs map[string]*Path, name string) (*Path, []string, err
return regexpPathConfs[i].Name < regexpPathConfs[j].Name
})
// check path against regexp-based path configs
// check path against regexp path configs
for _, pathConf := range regexpPathConfs {
m := pathConf.Regexp.FindStringSubmatch(name)
if m != nil {
+41
View File
@@ -18,3 +18,44 @@ func TestPathClone(t *testing.T) {
clone := original.Clone()
require.Equal(t, original, clone)
}
func TestIsValidPathName(t *testing.T) {
for _, ca := range []struct {
name string
path string
errMsg string
}{
{
name: "valid nested path",
path: "group/cam1",
},
{
name: "valid dots inside segment",
path: "cam.v1/main",
},
{
name: "parent directory",
path: "../cam1",
errMsg: "can't contain dot path segments",
},
{
name: "embedded parent directory",
path: "group/../cam1",
errMsg: "can't contain dot path segments",
},
{
name: "current directory",
path: "./cam1",
errMsg: "can't contain dot path segments",
},
} {
t.Run(ca.name, func(t *testing.T) {
err := IsValidPathName(ca.path)
if ca.errMsg != "" {
require.EqualError(t, err, ca.errMsg)
} else {
require.NoError(t, err)
}
})
}
}
+7
View File
@@ -122,6 +122,13 @@ func seekAndMux(
func (s *Server) onGet(ctx *gin.Context) {
pathName := ctx.Query("path")
// validate path name before passing it to the authentication manager
err := conf.IsValidPathName(pathName)
if err != nil {
s.writeError(ctx, http.StatusBadRequest, fmt.Errorf("invalid path name: %w (%s)", err, pathName))
return
}
if !s.doAuth(ctx, pathName) {
return
}
+35
View File
@@ -873,6 +873,41 @@ func TestOnGetInMiddleOfLastSample(t *testing.T) {
}
}
func TestOnGetInvalidPath(t *testing.T) {
s := &Server{
Address: "127.0.0.1:9996",
ReadTimeout: conf.Duration(10 * time.Second),
WriteTimeout: conf.Duration(10 * time.Second),
PathConfs: map[string]*conf.Path{
"all_others": {
Name: "all_others",
RecordPath: filepath.Join(t.TempDir(), "%path/%Y-%m-%d_%H-%M-%S-%f"),
RecordFormat: conf.RecordFormatFMP4,
},
},
AuthManager: test.NilAuthManager,
Parent: test.NilLogger,
}
err := s.Initialize()
require.NoError(t, err)
defer s.Close()
u, err := url.Parse("http://localhost:9996/get")
require.NoError(t, err)
v := url.Values{}
v.Set("path", "group/../cam1")
v.Set("start", time.Date(2008, 11, 7, 11, 23, 1, 500000000, time.Local).Format(time.RFC3339Nano))
v.Set("duration", "3")
u.RawQuery = v.Encode()
res, err := http.Get(u.String())
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
}
func TestOnGetBetweenSegments(t *testing.T) {
for _, ca := range []string{
"idr before",
+7
View File
@@ -151,6 +151,13 @@ func parseAndConcatenate(
func (s *Server) onList(ctx *gin.Context) {
pathName := ctx.Query("path")
// validate path name before passing it to the authentication manager
err := conf.IsValidPathName(pathName)
if err != nil {
s.writeError(ctx, http.StatusBadRequest, fmt.Errorf("invalid path name: %w (%s)", err, pathName))
return
}
if !s.doAuth(ctx, pathName) {
return
}
+33
View File
@@ -255,6 +255,39 @@ func writeDuration(f io.ReadWriteSeeker, d time.Duration) error {
return nil
}
func TestOnListInvalidPath(t *testing.T) {
s := &Server{
Address: "127.0.0.1:9996",
ReadTimeout: conf.Duration(10 * time.Second),
WriteTimeout: conf.Duration(10 * time.Second),
PathConfs: map[string]*conf.Path{
"all_others": {
Name: "all_others",
RecordPath: filepath.Join(t.TempDir(), "mypath/%Y-%m-%d_%H-%M-%S-%f"),
RecordFormat: conf.RecordFormatFMP4,
},
},
AuthManager: test.NilAuthManager,
Parent: test.NilLogger,
}
err := s.Initialize()
require.NoError(t, err)
defer s.Close()
u, err := url.Parse("http://localhost:9996/list")
require.NoError(t, err)
v := url.Values{}
v.Set("path", "group/../cam1")
u.RawQuery = v.Encode()
res, err := http.Get(u.String())
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
}
func TestOnListCachedDuration(t *testing.T) {
dir, err := os.MkdirTemp("", "mediamtx-playback")
require.NoError(t, err)
+6
View File
@@ -2,6 +2,7 @@ package recordstore
import (
"errors"
"fmt"
"io/fs"
"path/filepath"
"sort"
@@ -127,6 +128,11 @@ func FindSegments(
start *time.Time,
end *time.Time,
) ([]*Segment, error) {
// double protection against directory traversal attacks
if err := conf.IsValidPathName(pathName); err != nil {
return nil, fmt.Errorf("invalid path name: %w (%s)", err, pathName)
}
recordPath := PathAddExtension(
strings.ReplaceAll(pathConf.RecordPath, "%path", pathName),
pathConf.RecordFormat,