navidrome/server/middlewares.go
Deluan a41e656706 refactor(api): reuse existing helpers in API v1 auth
Use gg.V, slice.Map, chi's RequestSize, core/auth's encryption key and
ClientIPRateLimiter instead of local copies; share the grant idle expiry
constant and a Grant.LastActivity helper; pass last use as a time value.
2026-09-28 20:06:28 -04:00

401 lines
13 KiB
Go

package server
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils"
"github.com/unrolled/secure"
)
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
status := ww.Status()
message := fmt.Sprintf("HTTP: %s %s://%s%s", r.Method, scheme, r.Host, r.RequestURI)
logArgs := []any{
r.Context(),
message,
"remoteAddr", r.RemoteAddr,
"elapsedTime", time.Since(start),
"httpStatus", ww.Status(),
"responseSize", ww.BytesWritten(),
}
if log.IsGreaterOrEqualTo(log.LevelTrace) {
headers, _ := json.Marshal(r.Header)
logArgs = append(logArgs, "header", string(headers))
} else if log.IsGreaterOrEqualTo(log.LevelDebug) {
logArgs = append(logArgs, "userAgent", r.UserAgent())
}
switch {
case status >= 500:
log.Error(logArgs...)
case status >= 400:
log.Warn(logArgs...)
default:
log.Debug(logArgs...)
}
})
}
func loggerInjector(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = log.NewContext(r.Context(), "requestId", middleware.GetReqID(ctx))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func robotsTXT(fs fs.FS) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/robots.txt") {
r.URL.Path = "/robots.txt"
http.FileServerFS(fs).ServeHTTP(w, r)
} else {
next.ServeHTTP(w, r)
}
})
}
}
func corsHandler() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
},
AllowedHeaders: []string{"*"},
AllowCredentials: false,
ExposedHeaders: []string{"x-content-duration", "x-total-count", "x-nd-authorization"},
})
}
func secureMiddleware() func(http.Handler) http.Handler {
sec := secure.New(secure.Options{
ContentTypeNosniff: true,
FrameDeny: true,
ReferrerPolicy: "same-origin",
PermissionsPolicy: "autoplay=(), camera=(), microphone=(), usb=()",
CustomFrameOptionsValue: conf.Server.HTTPHeaders.FrameOptions,
//ContentSecurityPolicy: "script-src 'self' 'unsafe-inline'",
})
return sec.Handler
}
func compressMiddleware() func(http.Handler) http.Handler {
return middleware.Compress(
5,
"application/xml",
"application/json",
"application/javascript",
"text/html",
"text/plain",
"text/css",
"text/javascript",
"text/event-stream",
)
}
// clientUniqueIDMiddleware is a middleware that sets a unique client ID as a cookie if it's provided in the request header.
// If the unique client ID is not in the header but present as a cookie, it adds the ID to the request context.
func clientUniqueIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
clientUniqueId := r.Header.Get(consts.UIClientUniqueIDHeader)
// If clientUniqueId is found in the header, set it as a cookie
if clientUniqueId != "" {
c := &http.Cookie{
Name: consts.UIClientUniqueIDHeader,
Value: clientUniqueId,
MaxAge: consts.CookieExpiry,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
Path: cmp.Or(conf.Server.BasePath, "/"),
}
http.SetCookie(w, c)
} else {
// If clientUniqueId is not found in the header, check if it's present as a cookie
c, err := r.Cookie(consts.UIClientUniqueIDHeader)
if !errors.Is(err, http.ErrNoCookie) {
clientUniqueId = c.Value
}
}
// If a valid clientUniqueId is found, add it to the request context
if clientUniqueId != "" {
ctx = request.WithClientUniqueId(ctx, clientUniqueId)
r = r.WithContext(ctx)
}
// Call the next middleware or handler in the chain
next.ServeHTTP(w, r)
})
}
// realIPMiddleware resolves the request's client IP into the context, where it can be read with
// middleware.GetClientIP, and mirrors it into RemoteAddr for logging and player registration.
// Forwarding headers are only honoured when the peer is listed in ExtAuth.TrustedSources, so that
// a client cannot pick its own identity and evade controls keyed on it. The peer address is kept
// in the context as request.ReverseProxyIp.
func realIPMiddleware(next http.Handler) http.Handler {
trusted := conf.Server.ExtAuth.TrustedSources
fromPeer := middleware.ClientIPFromRemoteAddr(next)
if trusted == "" {
return fromPeer
}
// Last match wins, so this order reproduces RealIP's precedence: True-Client-IP, X-Real-IP,
// X-Forwarded-For, peer. Only X-Forwarded-For is checked against the trusted list.
fromProxy := chi.Chain(
middleware.ClientIPFromRemoteAddr,
middleware.ClientIPFromXFF(trustedProxyPrefixes(trusted)...),
middleware.ClientIPFromHeader("X-Real-IP"),
middleware.ClientIPFromHeader("True-Client-IP"),
).Handler(mirrorClientIP(next))
dispatch := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if validateIPAgainstList(r.RemoteAddr, trusted) {
fromProxy.ServeHTTP(w, r)
return
}
log.Trace(r.Context(), "Ignoring forwarding headers from untrusted peer", "peer", r.RemoteAddr)
fromPeer.ServeHTTP(w, r)
})
return reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr })(dispatch)
}
// mirrorClientIP copies the resolved client IP into RemoteAddr when it differs from the peer.
func mirrorClientIP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ip := middleware.GetClientIP(r.Context()); ip != "" && ip != peerHost(r) {
r.RemoteAddr = ip
}
next.ServeHTTP(w, r)
})
}
// peerHost returns the host part of RemoteAddr, which may already be a bare IP.
func peerHost(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// trustedProxyPrefixes returns the CIDR entries of a trusted sources list, skipping non-CIDR
// entries such as the "@" unix socket marker. An empty result makes ClientIPFromXFF trust
// exactly one hop.
func trustedProxyPrefixes(list string) []string {
var prefixes []string
for _, entry := range strings.Split(list, ",") {
entry = strings.TrimSpace(entry)
if _, err := netip.ParsePrefix(entry); err == nil {
prefixes = append(prefixes, entry)
}
}
return prefixes
}
// ClientIPRateLimiter returns a rate limiter keyed by ClientIP, so spoofed forwarding headers
// cannot be rotated for a fresh bucket.
func ClientIPRateLimiter(requestLimit int, windowLength time.Duration, opts ...httprate.Option) func(http.Handler) http.Handler {
return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) {
return ClientIP(r), nil
}, opts...)
}
// ClientIP returns the canonical client IP resolved by realIPMiddleware, for keying rate limits. The
// peer address fallback degrades a missing middleware to per-peer limiting, not one shared bucket.
func ClientIP(r *http.Request) string {
return httprate.CanonicalizeIP(ClientAddr(r))
}
// ClientAddr returns the client IP resolved by realIPMiddleware unmasked, for recording who made a request.
func ClientAddr(r *http.Request) string {
return cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))
}
// reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key
// can only be set once.
func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Context().Value(key) == nil {
ctx := context.WithValue(r.Context(), key, fn(r))
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
}
// serverAddressMiddleware is a middleware function that modifies the request object
// to reflect the address of the server handling the request, as determined by the
// presence of X-Forwarded-* headers or the scheme and host of the request URL.
func serverAddressMiddleware(h http.Handler) http.Handler {
// Define a new handler function that will be returned by this middleware function.
fn := func(w http.ResponseWriter, r *http.Request) {
// Call the ServerAddress function to get the scheme and host of the server
// handling the request. If a host is found, modify the request object to use
// that host and scheme instead of the original ones.
if rScheme, rHost := ServerAddress(r); rHost != "" {
r.Host = rHost
r.URL.Scheme = rScheme
// Recorded so code running without the request (e.g. plugins) can build public URLs.
r = r.WithContext(request.WithServerAddress(r.Context(), rScheme, rHost))
}
// Call the next handler in the chain with the modified request and response.
h.ServeHTTP(w, r)
}
// Return the new handler function as a http.Handler object.
return http.HandlerFunc(fn)
}
// Define constants for the X-Forwarded-* header keys.
var (
xForwardedHost = http.CanonicalHeaderKey("X-Forwarded-Host")
xForwardedProto = http.CanonicalHeaderKey("X-Forwarded-Proto")
xForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme")
)
// ServerAddress is a helper function that returns the scheme and host of the server
// handling the given request, as determined by the presence of X-Forwarded-* headers
// or the scheme and host of the request URL.
func ServerAddress(r *http.Request) (scheme, host string) {
// Save the original request host for later comparison.
origHost := r.Host
// Determine the protocol of the request based on the presence of a TLS connection.
protocol := "http"
if r.TLS != nil {
protocol = "https"
}
// Get the X-Forwarded-Host header and extract the first host name if there are
// multiple hosts listed. If there is no X-Forwarded-Host header, use the original
// request host as the default.
xfh := r.Header.Get(xForwardedHost)
if xfh != "" {
i := strings.Index(xfh, ",")
if i == -1 {
i = len(xfh)
}
xfh = xfh[:i]
}
host = cmp.Or(xfh, r.Host)
// Determine the protocol and scheme of the request based on the presence of
// X-Forwarded-* headers or the scheme of the request URL.
scheme = cmp.Or(
r.Header.Get(xForwardedProto),
r.Header.Get(xForwardedScheme),
r.URL.Scheme,
protocol,
)
// If the request host has changed due to the X-Forwarded-Host header, log a trace
// message with the original and new host values, as well as the scheme and URL.
if host != origHost {
log.Trace(r.Context(), "Request host has changed", "origHost", origHost, "host", host, "scheme", scheme, "url", r.URL)
}
// Return the scheme and host of the server handling the request.
return scheme, host
}
// URLParamsMiddleware is a middleware function that decodes the query string of
// the incoming HTTP request, adds the URL parameters from the routing context,
// and re-encodes the modified query string.
func URLParamsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Retrieve the routing context from the request context.
ctx := chi.RouteContext(r.Context())
// Parse the existing query string into a URL values map.
params, _ := url.ParseQuery(r.URL.RawQuery)
// Loop through each URL parameter in the routing context.
for i, key := range ctx.URLParams.Keys {
// Skip any wildcard URL parameter keys.
if strings.Contains(key, "*") {
continue
}
// Add the URL parameter key-value pair to the URL values map.
params.Add(":"+key, ctx.URLParams.Values[i])
}
// Re-encode the URL values map as a query string and replace the
// existing query string in the request.
r.URL.RawQuery = params.Encode()
// Call the next handler in the chain with the modified request and response.
next.ServeHTTP(w, r)
})
}
func UpdateLastAccessMiddleware(ds model.DataStore) func(next http.Handler) http.Handler {
userAccessLimiter := utils.Limiter{Interval: consts.UpdateLastAccessFrequency}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
usr, ok := request.UserFrom(ctx)
if ok {
userAccessLimiter.Do(usr.ID, func() {
start := time.Now()
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
err := ds.User().UpdateLastAccessAt(ctx, usr.ID)
if err != nil {
log.Warn(ctx, "Could not update user's lastAccessAt", "username", usr.UserName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "Update user's lastAccessAt", "username", usr.UserName,
"elapsed", time.Since(start))
}
})
}
next.ServeHTTP(w, r)
})
}
}