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:
+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)
|
||||
}
|
||||
Reference in New Issue
Block a user