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