mirror of
https://github.com/aler9/rtsp-simple-server
synced 2026-04-22 15:07:19 +08:00
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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user