Phase 1b/1c: server-side input handling and authentication
continuous-integration/drone/push Build encountered an error
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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user