mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-11 03:47:18 +02:00
The RealIP middleware rewrote RemoteAddr from the True-Client-IP, X-Real-IP and X-Forwarded-For headers on every request, including when no trusted reverse proxy was configured. The login rate limiters on /auth/login and the Jellyfin /Users/AuthenticateByName derived their bucket from that value, so an unauthenticated client could rotate a forwarding header and get a fresh bucket for every password attempt, defeating the brute-force protection. Resolve the client IP with chi's ClientIPFrom* middlewares instead. The forwarding headers are only honoured when ExtAuth.TrustedSources is set and the connecting peer is in that list, reusing the trust check that external authentication already applies; otherwise the peer address is used. The X-Forwarded-For chain is now walked against the trusted CIDRs rather than taking its leftmost entry, so a spoofed value prepended by the client is skipped. Both limiters now key on the resolved address. The resolved address is still mirrored into RemoteAddr, so request logging, player registration and the Jellyfin local-network check keep reporting the client rather than the proxy. Reported by gehan-psbc.
391 lines
13 KiB
Go
391 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 the client IP resolved by realIPMiddleware,
|
|
// so spoofed forwarding headers cannot be rotated for a fresh bucket. It falls back to the peer
|
|
// address, so that a missing middleware degrades to per-peer limiting rather than one shared bucket.
|
|
func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler {
|
|
return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) {
|
|
return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))), nil
|
|
})
|
|
}
|
|
|
|
// 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(ctx).UpdateLastAccessAt(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)
|
|
})
|
|
}
|
|
}
|