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:
nathan committed 2026-09-26 13:04:54 +10:00
1 parent 4976219857
commit 36303fecea
14 files changed
+708 -114

No files matched your search

+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)
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