Phase 1b/1c: server-side input handling and authentication
continuous-integration/drone/push Build encountered an error

Input handling:
- uploadLogo: client file name reduced to a safe base name with an image
  extension (path traversal and arbitrary-extension writes closed).
- extractZIP: zip-slip guard; entries that resolve outside the target are
  refused. Per-entry closes no longer pile up as defers.
- ffmpeg.path / vlc.path must be an existing regular file named ffmpeg,
  vlc or cvlc, checked both when saved and right before exec.
- Stream URLs passed to the external buffer must use a network scheme
  (http, https, rtsp, rtmp, rtp, udp, mms); file:, concat:, pipe: are
  refused.
- /download/ (backups with settings.json and authentication.json) requires
  the web session when web authentication is enabled.
- settings.json is written 0600; the Plex token is masked in every payload
  sent to the UI and the mask round-trips as "unchanged" on save.

Authentication:
- Passwords are stored with bcrypt. Existing HMAC-SHA256 records still
  verify (constant time) and are re-hashed on the first successful login.
  Username lookups compare in constant time.
- URL (?username=&password=) and HTTP Basic authentication verify the
  credentials per request via AuthenticateUser and no longer create a
  session token, which removes the unbounded token growth under Plex
  polling. Expired sessions are evicted whenever a new one is created.
- createFirstUserForAuthentication and checkAuthorizationLevel now return
  real errors instead of calling no-op closures.

Tests: src/security_test.go and src/internal/authentication/
authentication_test.go cover each of the above.
This commit is contained in:
2026-09-26 13:04:54 +10:00
parent 4976219857
commit 36303fecea
14 changed files with 708 additions and 114 deletions
+3 -2
View File
@@ -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
)
+6
View File
@@ -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=
+53 -53
View File
@@ -28,21 +28,14 @@ func activatedSystemAuthentication() (err error) {
func createFirstUserForAuthentication(username, password string) (token string, err error) {
var authenticationErr = func(err error) {
if err != nil {
if err = authentication.CreateDefaultUser(username, password); err != nil {
return
}
if token, err = authentication.UserAuthentication(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)
var userData = make(map[string]any)
userData["username"] = username
userData["authentication.web"] = true
@@ -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 {
if !Settings.AuthenticationM3U {
return
}
err = checkAuthorizationLevel(token, level)
}
level = "authentication.m3u"
case "xml":
if !Settings.AuthenticationXML {
return
}
level = "authentication.xml"
if Settings.AuthenticationXML {
token, err = authentication.UserAuthentication(username, password)
default:
return
}
userID, err := authentication.AuthenticateUser(username, password)
if err != nil {
return
}
err = checkAuthorizationLevel(token, level)
}
}
err = checkAuthorizationLevelForUser(userID, level)
return
}
func checkAuthorizationLevel(token, level string) (err error) {
var authenticationErr = func(err error) {
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 err != nil {
return
}
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")
}
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")
}
+8 -1
View File
@@ -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)
+18 -4
View File
@@ -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
+5 -1
View File
@@ -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
}
+5
View File
@@ -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
+97 -30
View File
@@ -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)
var salt = loginData["_salt"].(string)
var loginUsername = loginData["_username"].(string)
var loginPassword = loginData["_password"].(string)
if SHA256(username, salt) == loginUsername {
if SHA256(password, salt) == loginPassword {
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, "-")
for id, v := range users {
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
}
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:
evictExpiredTokensLocked()
for {
newToken = randomString(tokenLength)
if _, ok := tokens[newToken]; ok {
goto loopToken
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
@@ -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))
}
}
+135
View File
@@ -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)
}
+210
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
}
+17 -2
View File
@@ -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
+9 -9
View File
@@ -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