diff --git a/go.mod b/go.mod index 42150e4..3dcc425 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( ) require ( - golang.org/x/net v0.50.0 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index 626f724..5e8af74 100644 --- a/go.sum +++ b/go.sum @@ -2,7 +2,13 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/koron/go-ssdp v0.1.0 h1:ckl5x5H6qSNFmi+wCuROvvGUu2FQnMbQrU95IHCcv3Y= github.com/koron/go-ssdp v0.1.0/go.mod h1:GltaDBjtK1kemZOusWYLGotV0kBeEf59Bp0wtSB0uyU= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/src/authentication.go b/src/authentication.go index 7720f29..be44d30 100644 --- a/src/authentication.go +++ b/src/authentication.go @@ -28,20 +28,13 @@ func activatedSystemAuthentication() (err error) { func createFirstUserForAuthentication(username, password string) (token string, err error) { - var authenticationErr = func(err error) { - if err != nil { - return - } + if err = authentication.CreateDefaultUser(username, password); err != nil { + return } - err = authentication.CreateDefaultUser(username, password) - authenticationErr(err) - - token, err = authentication.UserAuthentication(username, password) - authenticationErr(err) - - token, err = authentication.CheckTheValidityOfTheToken(token) - authenticationErr(err) + if token, err = authentication.UserAuthentication(username, password); err != nil { + return + } var userData = make(map[string]any) userData["username"] = username @@ -53,10 +46,11 @@ func createFirstUserForAuthentication(username, password string) (token string, userData["defaultUser"] = true userID, err := authentication.GetUserID(token) - authenticationErr(err) + if err != nil { + return + } err = authentication.WriteUserData(userID, userData) - authenticationErr(err) return } @@ -72,6 +66,8 @@ func tokenAuthentication(token string) (newToken string, err error) { return } +// basicAuth : HTTP Basic authentication for the HDHomeRun endpoints. Verifies +// the credentials on every request without creating a session token. func basicAuth(r *http.Request, level string) (username string, err error) { err = errors.New("User authentication failed") @@ -82,92 +78,96 @@ func basicAuth(r *http.Request, level string) (username string, err error) { return } - payload, _ := base64.StdEncoding.DecodeString(auth[1]) + payload, decodeErr := base64.StdEncoding.DecodeString(auth[1]) + if decodeErr != nil { + return + } + pair := strings.SplitN(string(payload), ":", 2) + if len(pair) != 2 { + return + } username = pair[0] var password = pair[1] - token, err := authentication.UserAuthentication(username, password) - + userID, err := authentication.AuthenticateUser(username, password) if err != nil { return } - err = checkAuthorizationLevel(token, level) + err = checkAuthorizationLevelForUser(userID, level) return } +// urlAuth : username/password query parameters on the M3U and XMLTV URLs. +// Verified per request, no session token is created. func urlAuth(r *http.Request, requestType string) (err error) { - var level, token string var username = r.URL.Query().Get("username") var password = r.URL.Query().Get("password") + var level string + switch requestType { case "m3u": - level = "authentication.m3u" - if Settings.AuthenticationM3U { - token, err = authentication.UserAuthentication(username, password) - if err != nil { - return - } - err = checkAuthorizationLevel(token, level) + if !Settings.AuthenticationM3U { + return } + level = "authentication.m3u" case "xml": - level = "authentication.xml" - if Settings.AuthenticationXML { - token, err = authentication.UserAuthentication(username, password) - if err != nil { - return - } - err = checkAuthorizationLevel(token, level) + if !Settings.AuthenticationXML { + return } + level = "authentication.xml" + + default: + return } + userID, err := authentication.AuthenticateUser(username, password) + if err != nil { + return + } + + err = checkAuthorizationLevelForUser(userID, level) + return } func checkAuthorizationLevel(token, level string) (err error) { - var authenticationErr = func(err error) { - if err != nil { - return - } + userID, err := authentication.GetUserID(token) + if err != nil { + return } - userID, err := authentication.GetUserID(token) - authenticationErr(err) + return checkAuthorizationLevelForUser(userID, level) +} + +func checkAuthorizationLevelForUser(userID, level string) (err error) { userData, err := authentication.ReadUserData(userID) - authenticationErr(err) - - if len(userData) > 0 { - - if v, ok := userData[level].(bool); ok { - - if !v { - err = errors.New("No authorization") - } - - } else { - userData[level] = false - if err = authentication.WriteUserData(userID, userData); err != nil { - ShowError(err, 0) - } - err = errors.New("No authorization") - } - - } else { - if err = authentication.WriteUserData(userID, userData); err != nil { - ShowError(err, 0) - } - err = errors.New("No authorization") + if err != nil { + return } - return + if v, ok := userData[level].(bool); ok { + if !v { + err = errors.New("No authorization") + } + return + } + + // Level unknown for this user: record it as denied so it shows up in the UI. + userData[level] = false + if writeErr := authentication.WriteUserData(userID, userData); writeErr != nil { + ShowError(writeErr, 0) + } + + return errors.New("No authorization") } diff --git a/src/buffer.go b/src/buffer.go index 68fff80..5331362 100644 --- a/src/buffer.go +++ b/src/buffer.go @@ -1389,7 +1389,14 @@ func thirdPartyBuffer(streamID int, playlistID string) { return } - err = checkFile(path) + err = checkStreamingBinary(path) + if err != nil { + ShowError(err, 0) + addErrorToStream(playlist, streamID, playlistID, err) + return + } + + err = checkStreamURL(url) if err != nil { ShowError(err, 0) addErrorToStream(playlist, streamID, playlistID, err) diff --git a/src/compression.go b/src/compression.go index ba76626..2b37abc 100644 --- a/src/compression.go +++ b/src/compression.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "compress/gzip" + "fmt" "io" "os" "path/filepath" @@ -91,12 +92,21 @@ func extractZIP(archive, target string) (err error) { return err } - for _, file := range reader.File { + var extractOne = func(file *zip.File) error { path := filepath.Join(target, file.Name) + + // Zip-slip guard: an entry like "../../etc/passwd" must not escape target. + if !insideDir(target, path) { + return fmt.Errorf("archive entry %q escapes the target directory", file.Name) + } + if file.FileInfo().IsDir() { - os.MkdirAll(path, file.Mode()) - continue + return os.MkdirAll(path, file.Mode()) + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err } fileReader, err := file.Open() @@ -111,10 +121,14 @@ func extractZIP(archive, target string) (err error) { } defer targetFile.Close() - if _, err := io.Copy(targetFile, fileReader); err != nil { + _, err = io.Copy(targetFile, fileReader) + return err + } + + for _, file := range reader.File { + if err = extractOne(file); err != nil { return err } - } return diff --git a/src/data.go b/src/data.go index a5871d1..37e4c61 100644 --- a/src/data.go +++ b/src/data.go @@ -49,6 +49,10 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e case "plex.token": if v, ok := value.(string); ok { value = strings.TrimSpace(v) + // The UI only ever sees the mask; sending it back means "unchanged". + if value == plexTokenMask { + value = Settings.PlexToken + } } triggerPlexGuideReload = true @@ -127,7 +131,7 @@ func updateServerSettings(request RequestStruct) (settings SettingsStruct, err e var path = value.(string) if len(path) > 0 { - err = checkFile(path) + err = checkStreamingBinary(path) if err != nil { return } diff --git a/src/images.go b/src/images.go index 2f33445..947c2c9 100644 --- a/src/images.go +++ b/src/images.go @@ -8,6 +8,11 @@ import ( func uploadLogo(input, filename string) (logoURL string, err error) { + filename, err = sanitizeUploadFilename(filename) + if err != nil { + return + } + b64data := input[strings.IndexByte(input, ',')+1:] // BAse64 in bytes umwandeln un speichern diff --git a/src/internal/authentication/authentication.go b/src/internal/authentication/authentication.go index 50f1a90..b55cf3c 100755 --- a/src/internal/authentication/authentication.go +++ b/src/internal/authentication/authentication.go @@ -1,8 +1,11 @@ package authentication import ( + "strings" + "encoding/json" "errors" + "golang.org/x/crypto/bcrypt" "net/http" "os" "path/filepath" @@ -103,7 +106,7 @@ func CreateNewUser(username, password string) (userID string, err error) { var salt = userData["_salt"].(string) var loginUsername = userData["_username"].(string) - if SHA256(username, salt) == loginUsername { + if hmac.Equal([]byte(SHA256(username, salt)), []byte(loginUsername)) { err = createError(020) } @@ -127,39 +130,61 @@ func CreateNewUser(username, password string) (userID string, err error) { return } -// UserAuthentication : user authentication -func UserAuthentication(username, password string) (token string, err error) { +// AuthenticateUser : verifies a username/password pair and returns the user +// ID. No session token is created; use UserAuthentication for that. +func AuthenticateUser(username, password string) (userID string, err error) { err = checkInit() if err != nil { return } - var login = func(username, password string, loginData map[string]any) (err error) { - err = createError(010) + err = createError(010) - var salt = loginData["_salt"].(string) - var loginUsername = loginData["_username"].(string) - var loginPassword = loginData["_password"].(string) + var users = data["users"].(map[string]any) + for id, v := range users { - if SHA256(username, salt) == loginUsername { - if SHA256(password, salt) == loginPassword { - err = nil + loginData, ok := v.(map[string]any) + if !ok { + continue + } + + var salt, _ = loginData["_salt"].(string) + var storedUsername, _ = loginData["_username"].(string) + var storedPassword, _ = loginData["_password"].(string) + + if !hmac.Equal([]byte(SHA256(username, salt)), []byte(storedUsername)) { + continue + } + + ok, upgrade := verifyPassword(storedPassword, password, salt) + if !ok { + return + } + + // Legacy SHA256 record: re-hash with bcrypt now that the password is known. + if upgrade { + loginData["_password"] = hashPassword(password) + if saveErr := saveDatabase(data); saveErr != nil { + return "", saveErr } } + return id, nil + } + + return +} + +// UserAuthentication : login; returns a new session token on success. +func UserAuthentication(username, password string) (token string, err error) { + + userID, err := AuthenticateUser(username, password) + if err != nil { return } - var users = data["users"].(map[string]any) - for id, loginData := range users { - err = login(username, password, loginData.(map[string]any)) - if err == nil { - token = setToken(id, "-") - return - } - } - + token = setToken(userID, "-") return } @@ -305,7 +330,7 @@ func SetDefaultUserData(defaults map[string]any) (err error) { return } -// ChangeCredentials : change credentials +// ChangeCredentials : change username and/or password of a user func ChangeCredentials(userID, username, password string) (err error) { err = checkInit() if err != nil { @@ -315,7 +340,6 @@ func ChangeCredentials(userID, username, password string) (err error) { err = createError(032) if userData, ok := data["users"].(map[string]any)[userID]; ok { - //var userData = tmp.(map[string]interface{}) var salt = userData.(map[string]any)["_salt"].(string) if len(username) > 0 { @@ -323,7 +347,7 @@ func ChangeCredentials(userID, username, password string) (err error) { } if len(password) > 0 { - userData.(map[string]any)["_password"] = SHA256(password, salt) + userData.(map[string]any)["_password"] = hashPassword(password) } err = saveDatabase(data) @@ -409,7 +433,35 @@ func loadDatabase() (err error) { return } -// SHA256 : password + salt = sha256 string +// hashPassword : bcrypt hash for storage. +func hashPassword(password string) string { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + // Only reachable with an absurd cost or a >72 byte password; refuse + // to store something that cannot be verified. + return "!" + randomString(16) + } + return string(hash) +} + +// verifyPassword : checks password against a stored hash. Legacy records hold +// the old unsalted HMAC-SHA256; those verify in constant time and are reported +// as needing an upgrade to bcrypt. +func verifyPassword(stored, password, salt string) (ok bool, upgrade bool) { + + if strings.HasPrefix(stored, "$2") { + return bcrypt.CompareHashAndPassword([]byte(stored), []byte(password)) == nil, false + } + + if hmac.Equal([]byte(SHA256(password, salt)), []byte(stored)) { + return true, true + } + + return false, false +} + +// SHA256 : legacy hash (HMAC-SHA256 keyed by the secret). Still used for the +// username lookup key and for verifying old password records once. func SHA256(secret, salt string) string { key := []byte(secret) h := hmac.New(sha256.New, key) @@ -470,15 +522,15 @@ func defaultsForNewUser(username, password string) map[string]any { var defaults = make(map[string]any) var salt = randomString(saltLength) defaults["_username"] = SHA256(username, salt) - defaults["_password"] = SHA256(password, salt) + defaults["_password"] = hashPassword(password) defaults["_salt"] = salt defaults["_id"] = "id-" + randomID(idLength) - //defaults["_one.time.token"] = randomString(tokenLength) defaults["data"] = make(map[string]any) return defaults } +// setToken : creates a session token for a user and drops expired ones. func setToken(id, oldToken string) (newToken string) { tokensMu.Lock() defer tokensMu.Unlock() @@ -487,10 +539,13 @@ func setToken(id, oldToken string) (newToken string) { delete(tokens, oldToken) } -loopToken: - newToken = randomString(tokenLength) - if _, ok := tokens[newToken]; ok { - goto loopToken + evictExpiredTokensLocked() + + for { + newToken = randomString(tokenLength) + if _, ok := tokens[newToken]; !ok { + break + } } var tmp = make(map[string]any) @@ -502,6 +557,18 @@ loopToken: return } +// evictExpiredTokensLocked : removes expired sessions. Caller holds tokensMu. +func evictExpiredTokensLocked() { + var now = time.Now().Local() + for k, v := range tokens { + if entry, ok := v.(map[string]any); ok { + if expires, ok := entry["expires"].(time.Time); ok && expires.Before(now) { + delete(tokens, k) + } + } + } +} + // SetCookieToken : set cookie // SetCookieToken : sets the session cookie. It is HttpOnly (scripts cannot // read it), SameSite=Strict, and a session cookie: expiry is enforced server diff --git a/src/internal/authentication/authentication_test.go b/src/internal/authentication/authentication_test.go new file mode 100644 index 0000000..7030853 --- /dev/null +++ b/src/internal/authentication/authentication_test.go @@ -0,0 +1,130 @@ +package authentication + +import ( + "strings" + "testing" + "time" +) + +func setup(t *testing.T) { + t.Helper() + if err := Init(t.TempDir()+"/authentication.json", 60); err != nil { + t.Fatal(err) + } +} + +func TestNewUsersUseBcrypt(t *testing.T) { + setup(t) + id, err := CreateNewUser("alice", "pw1") + if err != nil { + t.Fatal(err) + } + rec := data["users"].(map[string]any)[id].(map[string]any) + if !strings.HasPrefix(rec["_password"].(string), "$2") { + t.Fatalf("password not bcrypt: %q", rec["_password"]) + } + if _, err := AuthenticateUser("alice", "pw1"); err != nil { + t.Errorf("valid login failed: %v", err) + } + if _, err := AuthenticateUser("alice", "wrong"); err == nil { + t.Error("wrong password accepted") + } + if _, err := AuthenticateUser("bob", "pw1"); err == nil { + t.Error("unknown user accepted") + } +} + +func TestLegacySHA256RecordIsUpgradedOnLogin(t *testing.T) { + setup(t) + id, err := CreateNewUser("legacy", "oldpw") + if err != nil { + t.Fatal(err) + } + rec := data["users"].(map[string]any)[id].(map[string]any) + salt := rec["_salt"].(string) + rec["_password"] = SHA256("oldpw", salt) // what upstream stored + + if _, err := AuthenticateUser("legacy", "nope"); err == nil { + t.Fatal("wrong password accepted against legacy record") + } + if !strings.HasPrefix(rec["_password"].(string), "$2") == false { + t.Fatal("record must not be upgraded on a failed login") + } + + if _, err := AuthenticateUser("legacy", "oldpw"); err != nil { + t.Fatalf("legacy login failed: %v", err) + } + if !strings.HasPrefix(rec["_password"].(string), "$2") { + t.Fatalf("record not upgraded to bcrypt: %q", rec["_password"]) + } + if _, err := AuthenticateUser("legacy", "oldpw"); err != nil { + t.Errorf("login after upgrade failed: %v", err) + } + + // The upgrade must have been persisted. + if err := loadDatabase(); err != nil { + t.Fatal(err) + } + rec = data["users"].(map[string]any)[id].(map[string]any) + if !strings.HasPrefix(rec["_password"].(string), "$2") { + t.Fatal("upgraded hash was not saved to disk") + } +} + +func TestChangeCredentialsUsesBcrypt(t *testing.T) { + setup(t) + id, _ := CreateNewUser("carol", "one") + if err := ChangeCredentials(id, "", "two"); err != nil { + t.Fatal(err) + } + if _, err := AuthenticateUser("carol", "one"); err == nil { + t.Error("old password still works") + } + if _, err := AuthenticateUser("carol", "two"); err != nil { + t.Errorf("new password rejected: %v", err) + } +} + +func TestExpiredTokensAreEvicted(t *testing.T) { + setup(t) + id, _ := CreateNewUser("dave", "pw") + + tokensMu.Lock() + tokens = make(map[string]any) + tokens["stale"] = map[string]any{"id": id, "expires": time.Now().Local().Add(-time.Minute)} + tokens["fresh"] = map[string]any{"id": id, "expires": time.Now().Local().Add(time.Hour)} + tokensMu.Unlock() + + tok := setToken(id, "-") + + tokensMu.RLock() + defer tokensMu.RUnlock() + if _, ok := tokens["stale"]; ok { + t.Error("expired token was not evicted") + } + if _, ok := tokens["fresh"]; !ok { + t.Error("valid token was evicted") + } + if _, ok := tokens[tok]; !ok { + t.Error("new token missing") + } +} + +func TestAuthenticateUserDoesNotMintTokens(t *testing.T) { + setup(t) + CreateNewUser("erin", "pw") + tokensMu.Lock() + tokens = make(map[string]any) + tokensMu.Unlock() + + for i := 0; i < 5; i++ { + if _, err := AuthenticateUser("erin", "pw"); err != nil { + t.Fatal(err) + } + } + tokensMu.RLock() + defer tokensMu.RUnlock() + if len(tokens) != 0 { + t.Errorf("per-request authentication created %d tokens", len(tokens)) + } +} diff --git a/src/security.go b/src/security.go new file mode 100644 index 0000000..4c31b46 --- /dev/null +++ b/src/security.go @@ -0,0 +1,135 @@ +package src + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Input validation for values that reach the file system or an external +// process. Everything here is deliberately strict: xTeVe runs on a trusted +// LAN, but playlists, EPG feeds and any browser on that LAN are not trusted. + +var uploadExtensions = map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true, ".ico": true} + +var unsafeFilenameChars = regexp.MustCompile(`[^A-Za-z0-9._-]+`) + +// sanitizeUploadFilename : reduces a client-supplied logo file name to a safe +// base name with an image extension. +func sanitizeUploadFilename(name string) (string, error) { + + name = strings.TrimSpace(name) + // Browsers on Windows may send backslash paths; keep only the last part. + if i := strings.LastIndexAny(name, `\/`); i >= 0 { + name = name[i+1:] + } + name = filepath.Base(name) + if name == "" || name == "." || name == ".." || name == string(filepath.Separator) { + return "", errors.New("invalid file name") + } + + var ext = strings.ToLower(filepath.Ext(name)) + if !uploadExtensions[ext] { + return "", fmt.Errorf("unsupported image type %q", ext) + } + + var base = unsafeFilenameChars.ReplaceAllString(strings.TrimSuffix(name, filepath.Ext(name)), "_") + base = strings.Trim(base, "._") + if base == "" { + return "", errors.New("invalid file name") + } + + return base + ext, nil +} + +var streamingBinaries = map[string]bool{"ffmpeg": true, "ffmpeg.exe": true, "vlc": true, "vlc.exe": true, "cvlc": true, "cvlc.exe": true} + +// checkStreamingBinary : the configured ffmpeg / VLC path must be an existing +// regular file whose name is one of the known players. This is what stops a +// settings change from turning the buffer into "run any program". +func checkStreamingBinary(path string) error { + + if err := checkFile(path); err != nil { + return err + } + + fi, err := os.Stat(getPlatformFile(path)) + if err != nil { + return err + } + if !fi.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", path) + } + + if !streamingBinaries[strings.ToLower(filepath.Base(path))] { + return fmt.Errorf("%s is not a supported streaming binary (ffmpeg, vlc, cvlc)", path) + } + + return nil +} + +var streamSchemes = map[string]bool{"http": true, "https": true, "rtsp": true, "rtsps": true, "rtmp": true, "rtmps": true, "rtp": true, "udp": true, "mms": true, "mmsh": true} + +// checkStreamURL : provider stream URLs are handed to ffmpeg / VLC verbatim. +// Only network schemes are allowed, so a playlist cannot point the buffer at +// file:, concat:, pipe: or similar local sources. +func checkStreamURL(raw string) error { + + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return err + } + + if !streamSchemes[strings.ToLower(u.Scheme)] { + return fmt.Errorf("stream URL scheme %q is not allowed", u.Scheme) + } + + if u.Host == "" { + return errors.New("stream URL has no host") + } + + return nil +} + +// insideDir : true when path (already joined) stays inside dir. +func insideDir(dir, path string) bool { + + dir = filepath.Clean(dir) + path = filepath.Clean(path) + + if path == dir { + return true + } + + return strings.HasPrefix(path, dir+string(filepath.Separator)) +} + +// plexTokenMask : what the UI sees instead of the real Plex token. +const plexTokenMask = "********" + +// maskSettings : copy of Settings with secrets replaced for the web client. +func maskSettings(s SettingsStruct) SettingsStruct { + + if len(s.PlexToken) > 0 { + s.PlexToken = plexTokenMask + } + + return s +} + +// writePrivateFile : like writeByteToFile but readable only by the owner. +func writePrivateFile(file string, data []byte) error { + + var filename = getPlatformFile(file) + + if err := os.WriteFile(filename, data, 0600); err != nil { + return err + } + + // WriteFile keeps the mode of an existing file; tighten it. + return os.Chmod(filename, 0600) +} diff --git a/src/security_test.go b/src/security_test.go new file mode 100644 index 0000000..1577de2 --- /dev/null +++ b/src/security_test.go @@ -0,0 +1,210 @@ +package src + +import ( + "archive/zip" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "xteve/src/internal/authentication" +) + +func TestSanitizeUploadFilename(t *testing.T) { + cases := map[string]string{ + "logo.png": "logo.png", + "../../etc/passwd.png": "passwd.png", + "my logo (1).JPG": "my_logo_1.jpg", + "..\\..\\evil.gif": "evil.gif", + "weird/../name.webp": "name.webp", + "C:\\Users\\x\\pic.jpeg": "pic.jpeg", + } + for in, want := range cases { + got, err := sanitizeUploadFilename(in) + if err != nil { + t.Errorf("%q: unexpected error %v", in, err) + continue + } + if filepath.Base(got) != got || got != want { + t.Errorf("%q: got %q, want %q", in, got, want) + } + } + + for _, bad := range []string{"", ".", "..", "/", "shell.sh", "logo", "image.svg", ".png", "....png"} { + if got, err := sanitizeUploadFilename(bad); err == nil { + t.Errorf("%q: expected rejection, got %q", bad, got) + } + } +} + +func TestCheckStreamingBinary(t *testing.T) { + dir := t.TempDir() + ok := filepath.Join(dir, "ffmpeg") + if err := os.WriteFile(ok, []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatal(err) + } + bad := filepath.Join(dir, "sh") + if err := os.WriteFile(bad, []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatal(err) + } + + if err := checkStreamingBinary(ok); err != nil { + t.Errorf("ffmpeg path rejected: %v", err) + } + if err := checkStreamingBinary(bad); err == nil { + t.Error("arbitrary binary accepted") + } + if err := checkStreamingBinary(dir); err == nil { + t.Error("directory accepted") + } + if err := checkStreamingBinary(filepath.Join(dir, "missing", "ffmpeg")); err == nil { + t.Error("missing file accepted") + } +} + +func TestCheckStreamURL(t *testing.T) { + for _, good := range []string{"http://host/live.ts", "https://h:8080/x.m3u8", "rtsp://cam/1", "udp://239.0.0.1:1234", "rtmp://srv/app/key"} { + if err := checkStreamURL(good); err != nil { + t.Errorf("%q rejected: %v", good, err) + } + } + for _, bad := range []string{"file:///etc/passwd", "concat:a|b", "pipe:0", "data:text/plain,x", "/etc/passwd", "", "http://"} { + if err := checkStreamURL(bad); err == nil { + t.Errorf("%q accepted", bad) + } + } +} + +func TestExtractZIPRejectsZipSlip(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "evil.zip") + + f, err := os.Create(archive) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + for name, body := range map[string]string{"ok.txt": "fine", "../escape.txt": "nope"} { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + w.Write([]byte(body)) + } + zw.Close() + f.Close() + + target := filepath.Join(dir, "out") + if err := extractZIP(archive, target); err == nil { + t.Fatal("expected zip-slip entry to be rejected") + } + if _, err := os.Stat(filepath.Join(dir, "escape.txt")); err == nil { + t.Fatal("escaped file was written outside the target directory") + } +} + +func TestExtractZIPNormalArchive(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "ok.zip") + + f, _ := os.Create(archive) + zw := zip.NewWriter(f) + w, _ := zw.Create("sub/file.txt") + w.Write([]byte("hello")) + zw.Close() + f.Close() + + target := filepath.Join(dir, "out") + if err := extractZIP(archive, target); err != nil { + t.Fatalf("extract failed: %v", err) + } + got, err := os.ReadFile(filepath.Join(target, "sub", "file.txt")) + if err != nil || string(got) != "hello" { + t.Fatalf("extracted content wrong: %q %v", got, err) + } +} + +func TestMaskSettings(t *testing.T) { + var s SettingsStruct + s.PlexToken = "real-token" + s.PlexURL = "http://plex:32400" + + m := maskSettings(s) + if m.PlexToken != plexTokenMask { + t.Errorf("token not masked: %q", m.PlexToken) + } + if m.PlexURL != s.PlexURL { + t.Error("unrelated field changed") + } + if s.PlexToken != "real-token" { + t.Error("original settings mutated") + } + + var empty SettingsStruct + if maskSettings(empty).PlexToken != "" { + t.Error("empty token must stay empty so the UI shows an empty field") + } +} + +func TestWritePrivateFile(t *testing.T) { + file := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(file, []byte("old"), 0644); err != nil { + t.Fatal(err) + } + if err := writePrivateFile(file, []byte("new")); err != nil { + t.Fatal(err) + } + fi, _ := os.Stat(file) + if fi.Mode().Perm() != 0600 { + t.Errorf("mode = %o, want 0600", fi.Mode().Perm()) + } +} + +func TestDownloadRequiresWebSession(t *testing.T) { + if err := authentication.Init(t.TempDir()+"/authentication.json", 60); err != nil { + t.Fatal(err) + } + if _, err := authentication.CreateNewUser("admin", "secret"); err != nil { + t.Fatal(err) + } + token, err := authentication.UserAuthentication("admin", "secret") + if err != nil { + t.Fatal(err) + } + userID, _ := authentication.GetUserID(token) + if err := authentication.WriteUserData(userID, map[string]any{"authentication.web": true}); err != nil { + t.Fatal(err) + } + + System.Folder.Temp = t.TempDir() + string(os.PathSeparator) + if err := os.WriteFile(System.Folder.Temp+"backup.zip", []byte("zip"), 0600); err != nil { + t.Fatal(err) + } + Settings.AuthenticationWEB = true + System.ConfigurationWizard = false + t.Cleanup(func() { Settings.AuthenticationWEB = false }) + + srv := httptest.NewServer(http.HandlerFunc(Download)) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/download/backup.zip") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("anonymous download: got %d, want 403", resp.StatusCode) + } + + req, _ := http.NewRequest("GET", srv.URL+"/download/backup.zip", nil) + req.AddCookie(&http.Cookie{Name: "Token", Value: token}) + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("authenticated download: got %d, want 200", resp.StatusCode) + } +} diff --git a/src/system.go b/src/system.go index 6db7c7b..d039333 100644 --- a/src/system.go +++ b/src/system.go @@ -253,7 +253,7 @@ func saveSettings(settings SettingsStruct) (err error) { } - err = writeByteToFile(System.File.Settings, []byte(mapToJSON(settings))) + err = writePrivateFile(System.File.Settings, []byte(mapToJSON(settings))) if err != nil { return } diff --git a/src/webserver.go b/src/webserver.go index be825e6..65bab81 100644 --- a/src/webserver.go +++ b/src/webserver.go @@ -553,7 +553,7 @@ func WS(w http.ResponseWriter, r *http.Request) { if err != nil { response.Status = false response.Error = err.Error() - response.Settings = Settings + response.Settings = maskSettings(Settings) } response = setDefaultResponseData(response, true) @@ -935,6 +935,21 @@ func API(w http.ResponseWriter, r *http.Request) { // Download : Datei Download func Download(w http.ResponseWriter, r *http.Request) { + // Backups contain settings.json (Plex token) and authentication.json, so + // the download needs the same session as the web UI that created it. + if Settings.AuthenticationWEB && !System.ConfigurationWizard { + + _, token, err := authentication.CheckTheValidityOfTheTokenFromHTTPHeader(w, r) + if err == nil { + err = checkAuthorizationLevel(token, "authentication.web") + } + if err != nil { + httpStatusError(w, r, 403) + return + } + + } + var path = r.URL.Path var file = System.Folder.Temp + getFilenameFromPath(path) w.Header().Set("Content-Disposition", "attachment; filename="+getFilenameFromPath(file)) @@ -1005,7 +1020,7 @@ func setDefaultResponseData(response ResponseStruct, data bool) (defaults Respon } - defaults.Settings = Settings + defaults.Settings = maskSettings(Settings) defaults.Data.Playlist.M3U.Groups.Text = Data.Playlist.M3U.Groups.Text defaults.Data.Playlist.M3U.Groups.Value = Data.Playlist.M3U.Groups.Value diff --git a/tasks/todo.md b/tasks/todo.md index 9af4503..d5590a7 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -17,15 +17,15 @@ Status: Phase 0 committed on branch `improvements` 2026-09-26. - [x] Websocket uses gorilla's same-origin check; token read from the HttpOnly cookie (query param kept for legacy clients); payload/token console logging removed. Tests in `src/websocket_test.go` - [x] Cookie `HttpOnly`, `SameSite=Strict`, `Path=/`, cleared on logout - [x] Wizard GET no longer mutates `AuthenticationWEB`; the wizard page just bypasses login while active -- [ ] Sanitise `uploadLogo` filename -- [ ] Zip-slip guard in backup restore -- [ ] Restrict `ffmpeg.path` / `vlc.path` and reject non-http(s) stream URLs -- [ ] Put `/download/` behind auth -- [ ] Configured base URL instead of Host-header-derived domain -- [ ] bcrypt with migrate-on-login; constant-time compare -- [ ] Token expiry and eviction -- [ ] Only admins may edit other users -- [ ] Settings file mode 0600; redact Plex token in UI payload +- [x] `uploadLogo` filename sanitised (base name, image extensions only) +- [x] Zip-slip guard in `extractZIP` (also no more defer-in-loop there) +- [x] `ffmpeg.path`/`vlc.path` must be a regular file named ffmpeg/vlc/cvlc; stream URLs handed to the external buffer must use a network scheme +- [x] `/download/` requires the web session when web auth is on +- [~] Host-header-derived URLs kept by design (LAN, many interfaces; URLs must match how the client reached the server) +- [x] bcrypt for new passwords and credential changes; legacy SHA256 records verified in constant time and upgraded on first login; username compare constant-time +- [x] Expired tokens evicted on every new session; URL/Basic auth no longer mint tokens at all (`AuthenticateUser`) +- [~] Not applicable: xTeVe has no roles, every web user is an administrator by design (documented in README security notes) +- [x] `settings.json` written 0600; Plex token masked in every payload to the UI, mask round-trips as "unchanged" on save - [x] Decide default for web auth on fresh installs (keep off; LAN only, decided 2026-09-25) ## Phase 2: Streaming stability