Vellum/internal/auth/middleware.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

234 lines
7.8 KiB
Go

package auth
import (
"context"
"database/sql"
"log/slog"
"net"
"net/http"
"strings"
"time"
"github.com/mithral/vellum/internal/httpx"
)
// contextKey is unexported so no other package can collide with our context values.
type contextKey string
const (
userContextKey contextKey = "auth.user"
sessionContextKey contextKey = "auth.session"
)
// defaultTouchEvery throttles last_seen writes. last_seen exists for session
// listing and expiry hygiene, not security, so minute granularity is plenty and
// a write per request is not worth the contention it causes on SQLite's single
// write lock — especially for media playback, where one seek fires several
// Range requests that would otherwise each take the write lock.
const defaultTouchEvery = 5 * time.Minute
// Authenticator loads sessions from cookies and enforces access.
type Authenticator struct {
DB *sql.DB
CookieName string
CookieTTL time.Duration
Secure bool
Log *slog.Logger
// TrustedProxies are the networks allowed to name the real client in
// X-Forwarded-For. Empty means trust nobody. Set from cfg.TrustedProxies.
TrustedProxies []*net.IPNet
// touchEvery is the minimum age of a session's last_seen before Middleware
// will write it again. A zero value means "touch on every request" (the
// pre-throttling behaviour), which is a safe fallback for hand-built values.
touchEvery time.Duration
}
// NewAuthenticator builds an Authenticator with sane cookie defaults.
func NewAuthenticator(db *sql.DB, cookieName string, ttl time.Duration, secure bool, log *slog.Logger) *Authenticator {
if cookieName == "" {
cookieName = "vellum_session"
}
return &Authenticator{
DB: db,
CookieName: cookieName,
CookieTTL: ttl,
Secure: secure,
Log: log,
touchEvery: defaultTouchEvery,
}
}
// Middleware attaches the current user to the request context when a valid
// session cookie is present. It never rejects a request: enforcement is the
// job of RequireAuth, so public routes can also see the caller.
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if token, err := a.TokenFromRequest(r); err == nil {
if sess, err := SessionByToken(ctx, a.DB, token); err == nil {
if u, err := GetUserByID(ctx, a.DB, sess.UserID); err == nil {
ctx = context.WithValue(ctx, userContextKey, u)
ctx = context.WithValue(ctx, sessionContextKey, sess)
// Throttle the last_seen write. It is for session listing
// and expiry hygiene, not security, so minute granularity
// is plenty; a write on every request would take SQLite's
// single write lock each time, and a media seek alone can
// issue several Range requests. The decision is made from
// the LastSeen we already loaded via SessionByToken, so no
// extra query is added.
if time.Since(sess.LastSeen) >= a.touchEvery {
// Best-effort; must not fail the request.
if err := TouchSession(ctx, a.DB, sess.ID); err != nil {
a.Log.Warn("touch session", "err", err)
}
}
r = r.WithContext(ctx)
} else if a.Log != nil {
a.Log.Warn("session references missing user", "user_id", sess.UserID)
}
} else if err != ErrNoSession && a.Log != nil {
a.Log.Warn("load session", "err", err)
}
}
next.ServeHTTP(w, r)
})
}
// RequireAuth rejects unauthenticated requests with 401.
func (a *Authenticator) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := CurrentUser(r.Context()); !ok {
httpx.WriteError(w, http.StatusUnauthorized, "authentication required")
return
}
next.ServeHTTP(w, r)
})
}
// RequireAdmin rejects authenticated non-admins with 403.
func (a *Authenticator) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := CurrentUser(r.Context())
if !ok {
httpx.WriteError(w, http.StatusUnauthorized, "authentication required")
return
}
if !u.IsAdmin {
httpx.WriteError(w, http.StatusForbidden, "administrator access required")
return
}
next.ServeHTTP(w, r)
})
}
// Require calls fn with the current user, or 401s if there is none.
func (a *Authenticator) Require(fn func(http.ResponseWriter, *http.Request, User)) http.Handler {
return a.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, _ := CurrentUser(r.Context())
fn(w, r, u)
}))
}
// CurrentUser returns the authenticated user, if any.
func CurrentUser(ctx context.Context) (User, bool) {
u, ok := ctx.Value(userContextKey).(User)
return u, ok
}
// CurrentSession returns the session backing the authenticated user, if any.
func CurrentSession(ctx context.Context) (Session, bool) {
s, ok := ctx.Value(sessionContextKey).(Session)
return s, ok
}
// ── cookies ────────────────────────────────────────────────────────────
// SetSessionCookie issues the session cookie. HttpOnly and SameSite=Lax are
// always on; Secure follows configuration (it must be off for plain-HTTP dev).
func (a *Authenticator) SetSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: a.CookieName,
Value: token,
Path: "/",
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
Secure: a.Secure,
SameSite: http.SameSiteLaxMode,
})
}
// ClearSessionCookie removes the session cookie.
func (a *Authenticator) ClearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: a.CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: a.Secure,
SameSite: http.SameSiteLaxMode,
})
}
// TokenFromRequest reads the session token from the cookie, or the
// Authorization: Bearer header for non-browser clients (native apps, CLIs).
func (a *Authenticator) TokenFromRequest(r *http.Request) (string, error) {
if c, err := r.Cookie(a.CookieName); err == nil && c.Value != "" {
return c.Value, nil
}
if h := r.Header.Get("Authorization"); len(h) > 7 && h[:7] == "Bearer " {
return h[7:], nil
}
return "", ErrNoSession
}
// ClientIP extracts a best-effort client address for logging and rate limiting.
//
// Behind a reverse proxy every request arrives from the proxy, so RemoteAddr
// alone keys the login limiter identically for every client on the internet:
// one attacker locks out a username for everyone, and sessions.ip records the
// proxy. X-Forwarded-For fixes that, but only for a peer we already trust —
// from an untrusted peer that header is attacker-controlled, so it is ignored
// outright and the connection's own address is used, as before.
//
// With a trusted peer the chain is walked right-to-left and the first address
// that is not itself a trusted proxy wins: everything to its right is a hop we
// control, and everything to its left was appended by a client we do not. If
// every hop is trusted the leftmost entry is the best available answer.
func (a *Authenticator) ClientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if !a.trusts(host) {
return host
}
chain := r.Header.Get("X-Forwarded-For")
if chain == "" {
return host
}
hops := strings.Split(chain, ",")
for i := len(hops) - 1; i >= 0; i-- {
if hop := strings.TrimSpace(hops[i]); !a.trusts(hop) {
return hop
}
}
return strings.TrimSpace(hops[0])
}
// trusts reports whether ip falls inside one of the configured proxy networks.
func (a *Authenticator) trusts(ip string) bool {
parsed := net.ParseIP(ip)
if parsed == nil {
return false
}
for _, n := range a.TrustedProxies {
if n.Contains(parsed) {
return true
}
}
return false
}