Vellum/internal/core/config_test.go
mithral 87c17d4854
Some checks failed
CI / ci (push) Has been cancelled
Make the login limiter correct behind a reverse proxy
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.
2026-10-12 00:45:27 -04:00

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