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.
234 lines
7.8 KiB
Go
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
|
|
}
|