feat(net): trusted-proxy depth so real client IPs survive a proxy — #2453
Fixes the defect the operator spotted in #370 immediately after it shipped: auth.ClientIP ignored X-Forwarded-For whenever RemoteAddr was public, so a proxy on a public address — a separate host, or a CDN, i.e. anyone running this publicly, since public means TLS means a proxy — recorded the PROXY for every session. created_ip and last_ip were then always equal and the "Address changed" signal could never fire. The feature looked like it worked and reported nothing. Replaced with the standard trusted-hop model (Rails, Caddy, Traefik, nginx). XFF grows left-to-right as each proxy appends the peer it received from, so for client -> CDN -> own-proxy -> app the app sees [client, CDN] with RemoteAddr = own-proxy, and the client sits at XFF[len - hops]: 0 RemoteAddr, XFF ignored — no proxy 1 the address your own proxy observed 2 through a CDN in front of your proxy Default 1, per the operator: publicly reachable means a TLS terminator in front. The cost is real and stated rather than hidden. hops >= 1 DECLARES that a proxy exists; set it with no proxy, or deeper than the actual chain, and the index reaches attacker-supplied entries, letting a visitor choose which address their own session shows — defeating exactly the detection #370 is for. That's inherent to the model, which is why 0 is a first-class value and the admin card says "count your proxies, don't guess high" instead of just exposing a number. Both mis-set shapes are pinned by tests so they stay known consequences rather than surprises. Migration 0053 + internal/netsettings, cached under an RWMutex. That's not an optimisation: ClientIP runs in RequireUser for every authenticated request, so a per-request query would put the database on the critical path of the whole API. New() always returns a usable service so a boot-time DB hiccup degrades to the default instead of breaking that path (rule #131), and Hops() is nil-safe because test routers construct middleware without it. RequireUser now takes a func() int rather than an int — the value is operator-editable at runtime while the middleware is built once at boot, and reading it per request is what makes a save take effect with no restart (rule #25). The admin card is verifiable, not just configurable: it reports the address the CURRENT setting resolves THIS request to, the raw forwarded chain, and the socket peer — so you set the number, save, and confirm the address matches the machine you're on. It also counts the arriving chain and says how many proxies that implies. GET/PUT both return that payload, PUT recomputed under the new value, so the effect is visible without a reload. Also fixes styling in the #370 card that CI could not catch: text-destructive and bg-destructive don't exist in this Tailwind config — the palette is colors.action.destructive — so the "Address changed" warning and the sign-out-others button were rendering unstyled. Both now use text-action-destructive / bg-action-destructive / text-action-fg. Not done here: requestlog.go still logs raw RemoteAddr and will disagree with the sessions UI about who connected. Left for its own change.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
||||
)
|
||||
|
||||
type networkSettingsResp struct {
|
||||
TrustedProxyHops int `json:"trusted_proxy_hops"`
|
||||
MaxHops int `json:"max_hops"`
|
||||
// DetectedClientIP is what the CURRENT setting resolves this very request
|
||||
// to. It's the difference between a number the operator has to reason
|
||||
// about and one they can verify: set the value, reload, and check the
|
||||
// address matches the machine you're sitting at.
|
||||
DetectedClientIP string `json:"detected_client_ip"`
|
||||
// ForwardedChain is the raw X-Forwarded-For as received, so an operator
|
||||
// whose detected address looks wrong can see how many hops actually
|
||||
// arrived and count them rather than guess.
|
||||
ForwardedChain string `json:"forwarded_chain"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
}
|
||||
|
||||
type updateNetworkSettingsReq struct {
|
||||
TrustedProxyHops int `json:"trusted_proxy_hops"`
|
||||
}
|
||||
|
||||
func (h *handlers) handleGetNetworkSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.networkSettingsPayload(r))
|
||||
}
|
||||
|
||||
func (h *handlers) handleUpdateNetworkSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req updateNetworkSettingsReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
||||
return
|
||||
}
|
||||
if err := h.netSettings.SetHops(r.Context(), req.TrustedProxyHops); err != nil {
|
||||
if errors.Is(err, netsettings.ErrHopsOutOfRange) {
|
||||
writeErr(w, apierror.BadRequest("invalid_hops", err.Error()))
|
||||
return
|
||||
}
|
||||
writeErrWithLog(w, h.logger, "admin network: update failed", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
// Echo the payload recomputed under the NEW value, so the card can show
|
||||
// immediately what the change did to this request's own address rather
|
||||
// than making the operator reload to find out.
|
||||
writeJSON(w, http.StatusOK, h.networkSettingsPayload(r))
|
||||
}
|
||||
|
||||
func (h *handlers) networkSettingsPayload(r *http.Request) networkSettingsResp {
|
||||
hops := h.netSettings.Hops()
|
||||
return networkSettingsResp{
|
||||
TrustedProxyHops: hops,
|
||||
MaxHops: netsettings.MaxTrustedProxyHops,
|
||||
DetectedClientIP: auth.ClientIP(r, hops),
|
||||
ForwardedChain: r.Header.Get("X-Forwarded-For"),
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -20,6 +20,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||
@@ -30,7 +31,7 @@ import (
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
@@ -51,6 +52,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
eventbus: bus,
|
||||
playlistScheduler: playlistScheduler,
|
||||
streamSecret: streamSecret,
|
||||
netSettings: netSettings,
|
||||
}
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
@@ -74,7 +76,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
||||
|
||||
api.Group(func(authed chi.Router) {
|
||||
authed.Use(auth.RequireUser(pool))
|
||||
authed.Use(auth.RequireUser(pool, netSettings.Hops))
|
||||
authed.Post("/auth/logout", h.handleLogout)
|
||||
authed.Get("/me", h.handleGetMe)
|
||||
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
||||
@@ -185,6 +187,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
admin.Post("/albums/{id}/cover/refetch", h.handleAdminAlbumRefetchCover)
|
||||
admin.Post("/covers/refetch-missing", h.handleAdminBulkRefetchCovers)
|
||||
|
||||
admin.Get("/network-settings", h.handleGetNetworkSettings)
|
||||
admin.Put("/network-settings", h.handleUpdateNetworkSettings)
|
||||
|
||||
admin.Get("/scan/status", h.handleGetScanStatus)
|
||||
admin.Post("/scan/run", h.handleTriggerScan)
|
||||
|
||||
@@ -264,6 +269,9 @@ type handlers struct {
|
||||
mailer mailer.Sender
|
||||
eventbus *eventbus.Bus
|
||||
playlistScheduler *playlists.Scheduler
|
||||
// netSettings caches the trusted reverse-proxy depth read by the auth
|
||||
// middleware on every request and edited from the admin network card.
|
||||
netSettings *netsettings.Service
|
||||
// streamSecret is the HMAC key used by SignStreamToken /
|
||||
// VerifyStreamToken to authenticate the UPnP-speaker stream path
|
||||
// (see internal/api/stream_token.go and the design at
|
||||
|
||||
Reference in New Issue
Block a user