Some checks failed
CI / ci (push) Has been cancelled
ClientIP used r.RemoteAddr and ignored X-Forwarded-For. That was right about not trusting the header from an untrusted peer, but it is only half the design: behind a proxy every request arrives from the proxy, so the limiter key in handlers.go was "<proxy-ip>|<username>" for every client on the internet. One attacker locked out a username for everyone, an attacker rotating usernames never hit a limit, and sessions.ip recorded the proxy so the device list was useless. - VELLUM_TRUSTED_PROXIES is a CIDR list. X-Forwarded-For is honoured only when RemoteAddr is inside it, and the chain is walked right-to-left so the first address that is not itself a trusted proxy wins. Everything to its left was appended by a client we do not control, so a spoofed leftmost entry is discarded. - VELLUM_PUBLIC_URL is the external origin. CookieSecure now defaults to true when it is https://, so a TLS deployment that forgets VELLUM_COOKIE_SECURE no longer ships a plaintext session cookie. - A malformed CIDR fails the boot rather than being skipped: silently dropping a proxy network widens the trust boundary, which is the exact failure this setting exists to prevent. ClientIP became a method on Authenticator so it can see the trusted set, which is why handlers.go and main.go changed as well. Verified red-green: neutering the trusted-peer path fails five ClientIP tests, including the spoofed-leftmost case. Known gap, pre-existing and not fixed here: this limiter still covers only /api/v1/auth/login. Jellyfin AuthenticateByName and Subsonic auth call auth.Authenticate with no limiting, so the same brute force is available through those two surfaces.
144 lines
4.2 KiB
Go
144 lines
4.2 KiB
Go
package core
|
|
|
|
import (
|
|
"net"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestLoadConfigDefaults(t *testing.T) {
|
|
// No environment set: every field should have a usable default.
|
|
cfg, err := LoadConfig()
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig: %v", err)
|
|
}
|
|
if cfg.Listen != ":7788" {
|
|
t.Errorf("Listen = %q, want :7788", cfg.Listen)
|
|
}
|
|
if !filepath.IsAbs(cfg.DataDir) {
|
|
t.Errorf("DataDir = %q, want absolute", cfg.DataDir)
|
|
}
|
|
if cfg.DBPath != filepath.Join(cfg.DataDir, "vellum.db") {
|
|
t.Errorf("DBPath = %q, want <DataDir>/vellum.db", cfg.DBPath)
|
|
}
|
|
if cfg.LogLevel != "info" {
|
|
t.Errorf("LogLevel = %q, want info", cfg.LogLevel)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigFromEnv(t *testing.T) {
|
|
t.Setenv("VELLUM_LISTEN", "127.0.0.1:9999")
|
|
t.Setenv("VELLUM_DATA_DIR", "/tmp/vellum-data")
|
|
t.Setenv("VELLUM_MEDIA_DIRS", "/media/a:/media/b")
|
|
t.Setenv("VELLUM_SESSION_TTL", "48h")
|
|
|
|
cfg, err := LoadConfig()
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig: %v", err)
|
|
}
|
|
if cfg.Listen != "127.0.0.1:9999" {
|
|
t.Errorf("Listen = %q", cfg.Listen)
|
|
}
|
|
if cfg.DataDir != "/tmp/vellum-data" {
|
|
t.Errorf("DataDir = %q", cfg.DataDir)
|
|
}
|
|
if len(cfg.MediaDirs) != 2 || cfg.MediaDirs[0] != "/media/a" {
|
|
t.Errorf("MediaDirs = %v", cfg.MediaDirs)
|
|
}
|
|
if cfg.SessionTTL.Hours() != 48 {
|
|
t.Errorf("SessionTTL = %v, want 48h", cfg.SessionTTL)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigTrustedProxies(t *testing.T) {
|
|
t.Setenv("VELLUM_TRUSTED_PROXIES", "172.16.0.0/12, 127.0.0.1/32")
|
|
|
|
cfg, err := LoadConfig()
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig: %v", err)
|
|
}
|
|
if len(cfg.TrustedProxies) != 2 {
|
|
t.Fatalf("TrustedProxies = %v, want 2 entries", cfg.TrustedProxies)
|
|
}
|
|
if !cfg.TrustedProxies[0].Contains(net.ParseIP("172.20.0.5")) {
|
|
t.Errorf("TrustedProxies[0] = %v, want it to contain 172.20.0.5", cfg.TrustedProxies[0])
|
|
}
|
|
if cfg.TrustedProxies[1].Contains(net.ParseIP("172.20.0.5")) {
|
|
t.Errorf("TrustedProxies[1] = %v, want it to contain only 127.0.0.1", cfg.TrustedProxies[1])
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigRejectsBadTrustedProxy(t *testing.T) {
|
|
// A malformed entry must fail the boot: silently dropping it would quietly
|
|
// narrow the trust boundary and the operator would never know.
|
|
t.Setenv("VELLUM_TRUSTED_PROXIES", "172.16.0.0/12, not-a-cidr")
|
|
|
|
_, err := LoadConfig()
|
|
if err == nil {
|
|
t.Fatal("expected error for malformed VELLUM_TRUSTED_PROXIES")
|
|
}
|
|
if !strings.Contains(err.Error(), "not-a-cidr") {
|
|
t.Errorf("error %q should name the offending entry", err)
|
|
}
|
|
}
|
|
|
|
func TestCookieSecureDefaultsFromPublicURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
public string
|
|
cookie string // empty means unset
|
|
want bool
|
|
}{
|
|
{name: "https origin secures the cookie", public: "https://media.example.com", want: true},
|
|
{name: "http origin leaves it off", public: "http://media.example.com", want: false},
|
|
{name: "unset origin leaves it off", want: false},
|
|
{name: "explicit false overrides https", public: "https://media.example.com", cookie: "false", want: false},
|
|
{name: "explicit true overrides http", public: "http://media.example.com", cookie: "true", want: true},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Setenv("VELLUM_PUBLIC_URL", tc.public)
|
|
// An empty value reads as unset to the env helpers, and t.Setenv
|
|
// restores the ambient value either way.
|
|
t.Setenv("VELLUM_COOKIE_SECURE", tc.cookie)
|
|
|
|
cfg, err := LoadConfig()
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig: %v", err)
|
|
}
|
|
if cfg.PublicURL != tc.public {
|
|
t.Errorf("PublicURL = %q, want %q", cfg.PublicURL, tc.public)
|
|
}
|
|
if cfg.CookieSecure != tc.want {
|
|
t.Errorf("CookieSecure = %v, want %v", cfg.CookieSecure, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsMissingMediaDir(t *testing.T) {
|
|
cfg := Config{
|
|
DataDir: t.TempDir(),
|
|
ConfigDir: t.TempDir(),
|
|
MediaDirs: []string{"/definitely/not/a/real/path"},
|
|
}
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatal("expected error for missing media dir")
|
|
}
|
|
if !strings.Contains(err.Error(), "/definitely/not/a/real/path") {
|
|
t.Errorf("error %q should name the offending path", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateCreatesDirs(t *testing.T) {
|
|
base := t.TempDir()
|
|
cfg := Config{
|
|
DataDir: filepath.Join(base, "data"),
|
|
ConfigDir: filepath.Join(base, "config"),
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
t.Fatalf("Validate: %v", err)
|
|
}
|
|
}
|