Files
Bose-SoundTouch/pkg/service/handlers/middleware_realip.go
T
Tobias GesellchenandClaude Opus 4.7 f951fc92df feat(handlers): proxy-aware RemoteAddr via opt-in TrustForwardedHeaders
Wire up X-Real-IP / X-Forwarded-For / True-Client-IP support for
deployments fronted by a reverse proxy, while staying safe on flat-LAN
deployments where a malicious speaker could spoof those headers
directly.

Two new fields on `datastore.Settings`:

* TrustForwardedHeaders (bool, default false) — opt-in switch.
* TrustedProxyCIDRs ([]string, default `["127.0.0.0/8", "::1/128"]`)
  — only requests whose immediate TCP peer falls in one of these
  blocks may have their source IP rewritten from forwarded headers.
  Loopback default matches the documented same-host nginx layout in
  docs/guides/HTTPS-SETUP.md.

New middleware in `pkg/service/handlers/middleware_realip.go`:

* TrustedRealIP wraps `chi/middleware.RealIP` with a trusted-peer
  gate. When the immediate TCP peer is in the allowlist, chi's
  parsing handles the actual header → IP rewrite. When it isn't
  (e.g. a speaker sending forwarded headers itself), we ignore the
  headers and r.RemoteAddr stays as-is.
* ParseTrustedProxyCIDRs converts string CIDRs into *net.IPNet,
  applying the loopback default on empty input and erroring loudly
  on invalid entries.

Server.TrustedRealIPMiddleware() returns the middleware (or nil) by
reading the live settings; the router setup in
cmd/soundtouch-service/main.go installs it as the very first
middleware so SnapshotMiddleware and downstream handlers see the
correct r.RemoteAddr.

HandleMargePowerOn now prefers r.RemoteAddr over the body's
self-reported `<IPAddress>` for outbound credential push:

* The body field is treated as a hint only — a malicious LAN speaker
  could set it to any value; using it for outbound HTTP requests is
  the SSRF surface the previous zeroconf hardening was guarding
  against from the sink side. Fixing it at the source as well closes
  the gap entirely.
* When body IP and TCP source disagree, a log line names both and
  the device ID so the discrepancy is investigable.
* RemoteAddr is unparseable → fall back to the body so we don't
  silently drop the priming.

docs/guides/HTTPS-SETUP.md gains a follow-up note next to the existing
nginx snippet explaining the new flag, the loopback-only default, and
the explicit warning against enabling the flag on a flat-LAN
deployment without a real proxy.

Eleven test cases in middleware_realip_test.go lock in the gate
behaviour: trusted peers honoured for X-Real-IP / X-Forwarded-For /
no-headers / IPv6, untrusted peers' headers ignored, garbage values
rejected, ParseTrustedProxyCIDRs covers default / override / invalid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:40:15 +02:00

96 lines
2.6 KiB
Go

package handlers
import (
"fmt"
"net"
"net/http"
"github.com/go-chi/chi/v5/middleware"
)
// defaultTrustedProxyCIDRs is the safe-by-default list applied when
// Settings.TrustedProxyCIDRs is empty. Only loopback addresses are trusted —
// i.e. a reverse proxy on the same host. Anyone deploying behind a proxy on a
// different host must override this in settings.json.
var defaultTrustedProxyCIDRs = []string{
"127.0.0.0/8",
"::1/128",
}
// TrustedRealIP returns a middleware that delegates to chi's RealIP — which
// rewrites r.RemoteAddr from True-Client-IP / X-Real-IP / X-Forwarded-For
// headers — but only when the immediate TCP peer is in `trustedPeers`. For
// any request whose peer is *not* trusted (i.e. anything other than the
// configured reverse proxy), the headers are ignored and r.RemoteAddr stays
// as-is.
//
// This avoids the standard X-Forwarded-* spoofing pitfall: on a flat LAN
// where a malicious speaker could send the headers itself, we won't honour
// them; behind a reverse proxy we will.
//
// Returns nil if trustedPeers is empty — caller should not Use a nil mw.
func TrustedRealIP(trustedPeers []*net.IPNet) func(http.Handler) http.Handler {
if len(trustedPeers) == 0 {
return nil
}
delegate := middleware.RealIP
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isFromTrustedPeer(r.RemoteAddr, trustedPeers) {
delegate(next).ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}
}
// isFromTrustedPeer reports whether remoteAddr (in the host:port shape that
// net/http populates) is contained in any of the supplied CIDR blocks.
func isFromTrustedPeer(remoteAddr string, trustedPeers []*net.IPNet) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
for _, n := range trustedPeers {
if n.Contains(ip) {
return true
}
}
return false
}
// ParseTrustedProxyCIDRs converts string CIDRs into *net.IPNet values, falling
// back to defaultTrustedProxyCIDRs when the input is empty. An invalid CIDR
// in the input list is reported as an error and stops parsing — better to
// fail loud than silently fall back.
func ParseTrustedProxyCIDRs(cidrs []string) ([]*net.IPNet, error) {
if len(cidrs) == 0 {
cidrs = defaultTrustedProxyCIDRs
}
out := make([]*net.IPNet, 0, len(cidrs))
for _, c := range cidrs {
_, n, err := net.ParseCIDR(c)
if err != nil {
return nil, fmt.Errorf("invalid trusted proxy CIDR %q: %w", c, err)
}
out = append(out, n)
}
return out, nil
}