Add trusted reverse proxy settings

This commit is contained in:
Jonathon Leight committed 2026-06-23 19:11:54 -04:00
1 parent 398560891d
commit 845d285e90
7 files changed
+284 -56

No files matched your search

+1
View File
@@ -80,6 +80,7 @@ must serve the confirm/control pages over HTTPS.
| `MESHTENDER_MASTER_KEY` | 64 hex chars (32 bytes); AES-GCM key encrypting the identity seed at rest (required) |
| `MESHTENDER_ADDR` | listen address (default `:8080`) |
| `MESHTENDER_RP_ID` / `_RP_ORIGIN` / `_RP_NAME` | WebAuthn relying-party settings |
| `MESHTENDER_TRUSTED_PROXIES` | proxies whose `X-Forwarded-For`/`X-Real-IP` are trusted when resolving the client IP — comma-separated CIDRs/IPs, or `private` for the RFC1918/link-local/ULA ranges. Loopback is always trusted. Verify with the admin **Reverse proxy test** page. |
> **Note:** `MESHTENDER_MASTER_KEY` is coupled to the stored identity — changing it makes the
> existing `server_identity` row undecryptable. Keep it stable.
+72 -11
View File
@@ -4,6 +4,7 @@ package config
import (
"encoding/hex"
"fmt"
"net"
"os"
"strings"
)
@@ -61,6 +62,16 @@ type Config struct {
// production TLS usually terminates at a proxy and these stay empty.
TLSCert string
TLSKey string
// TrustedProxies are CIDR ranges whose X-Forwarded-For / X-Real-IP headers are
// trusted when resolving a request's client IP. Loopback is always trusted (a
// same-host reverse proxy). The client IP is the rightmost X-Forwarded-For
// entry that is NOT a trusted proxy; if the connecting peer itself isn't
// trusted, forwarding headers are ignored entirely (so they can't be spoofed).
// Configured via MESHTENDER_TRUSTED_PROXIES — a comma-separated list of CIDRs
// or bare IPs, plus the shorthand token "private" (adds the RFC1918, link-local
// and ULA ranges — handy when a home router/LAN sits in front).
TrustedProxies []*net.IPNet
}
// RadioDefaults is a set of LoRa parameters, used to match a repeater's stored
@@ -76,17 +87,18 @@ type RadioDefaults struct {
// validating required fields.
func Load() (*Config, error) {
c := &Config{
Addr: envOr("MESHTENDER_ADDR", ":8080"),
DatabaseURL: os.Getenv("MESHTENDER_DATABASE_URL"),
RPID: envOr("MESHTENDER_RP_ID", "localhost"),
RPDisplayName: envOr("MESHTENDER_RP_NAME", "MeshTender"),
RPOrigins: splitOrigins(envOr("MESHTENDER_RP_ORIGIN", "http://localhost:8080")),
PrimaryHost: envOr("MESHTENDER_PRIMARY_HOST", envOr("MESHTENDER_RP_ID", "localhost")),
AuthHost: os.Getenv("MESHTENDER_AUTH_HOST"),
RootHost: os.Getenv("MESHTENDER_ROOT_HOST"),
WWWHost: os.Getenv("MESHTENDER_WWW_HOST"),
TLSCert: os.Getenv("MESHTENDER_TLS_CERT"),
TLSKey: os.Getenv("MESHTENDER_TLS_KEY"),
Addr: envOr("MESHTENDER_ADDR", ":8080"),
DatabaseURL: os.Getenv("MESHTENDER_DATABASE_URL"),
RPID: envOr("MESHTENDER_RP_ID", "localhost"),
RPDisplayName: envOr("MESHTENDER_RP_NAME", "MeshTender"),
RPOrigins: splitOrigins(envOr("MESHTENDER_RP_ORIGIN", "http://localhost:8080")),
PrimaryHost: envOr("MESHTENDER_PRIMARY_HOST", envOr("MESHTENDER_RP_ID", "localhost")),
AuthHost: os.Getenv("MESHTENDER_AUTH_HOST"),
RootHost: os.Getenv("MESHTENDER_ROOT_HOST"),
WWWHost: os.Getenv("MESHTENDER_WWW_HOST"),
TLSCert: os.Getenv("MESHTENDER_TLS_CERT"),
TLSKey: os.Getenv("MESHTENDER_TLS_KEY"),
TrustedProxies: parseTrustedProxies(os.Getenv("MESHTENDER_TRUSTED_PROXIES")),
}
if c.RootHost != "" && c.WWWHost == "" {
@@ -120,6 +132,55 @@ func Load() (*Config, error) {
return c, nil
}
// privateRanges are added by the "private" shorthand token.
var privateRanges = []string{
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16",
"fc00::/7", "fe80::/10",
}
// parseTrustedProxies parses MESHTENDER_TRUSTED_PROXIES into CIDR ranges. Loopback
// is always included (a same-host reverse proxy). Each entry may be a CIDR, a bare
// IP (treated as a /32 or /128), or the token "private"/"loopback". Unparseable
// entries are skipped.
func parseTrustedProxies(s string) []*net.IPNet {
nets := []*net.IPNet{mustCIDR("127.0.0.0/8"), mustCIDR("::1/128")}
for _, tok := range strings.Split(s, ",") {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
switch strings.ToLower(tok) {
case "loopback":
continue // already included
case "private":
for _, c := range privateRanges {
nets = append(nets, mustCIDR(c))
}
continue
}
if strings.Contains(tok, "/") {
if _, n, err := net.ParseCIDR(tok); err == nil {
nets = append(nets, n)
}
continue
}
if ip := net.ParseIP(tok); ip != nil {
bits := 32
if ip.To4() == nil {
bits = 128
}
mask := net.CIDRMask(bits, bits)
nets = append(nets, &net.IPNet{IP: ip.Mask(mask), Mask: mask})
}
}
return nets
}
func mustCIDR(s string) *net.IPNet {
_, n, _ := net.ParseCIDR(s)
return n
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
+27 -2
View File
@@ -74,9 +74,34 @@ func (s *Handlers) pageProxyTest(w http.ResponseWriter, r *http.Request) {
scheme = "https"
}
// Trusted-proxy diagnostics: which ranges are trusted, whether the peer is one,
// and how each X-Forwarded-For hop is classified (the resolved client is the
// rightmost untrusted hop).
trusted := s.Cfg.TrustedProxies
trustedList := make([]string, 0, len(trusted))
for _, n := range trusted {
trustedList = append(trustedList, n.String())
}
type xffHop struct {
IP string
Trusted bool
Selected bool
}
var chain []xffHop
for _, raw := range strings.Split(r.Header.Get("X-Forwarded-For"), ",") {
ip := strings.TrimSpace(raw)
if ip == "" {
continue
}
chain = append(chain, xffHop{IP: ip, Trusted: web.IsTrustedProxy(ip, trusted), Selected: ip == resolved})
}
s.Render(w, r, "proxy_test.html", map[string]any{
"ResolvedIP": resolved,
"RawPeer": rawPeer,
"ResolvedIP": resolved,
"RawPeer": rawPeer,
"PeerTrusted": web.IsTrustedProxy(rawHost, trusted),
"TrustedProxies": trustedList,
"XFFChain": chain,
// HeaderApplied is true when a forwarding header changed the recorded IP
// away from the real peer — i.e. the proxy's headers are being trusted.
// When false, either this was a direct connection (fine) or the proxy
+31
View File
@@ -48,6 +48,37 @@
</div>
{{end}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Trusted-proxy resolution</h3></div>
<div class="card-body">
<div class="mb-3">
<div class="form-label">Connecting peer</div>
<span class="font-monospace">{{.RawPeer}}</span>
{{if .PeerTrusted}}<span class="badge bg-success-lt ms-1">trusted</span>{{else}}<span class="badge bg-yellow-lt ms-1">not trusted</span>{{end}}
{{if not .PeerTrusted}}
<div class="text-secondary small mt-1">The connecting peer isn't a trusted proxy, so forwarding headers are ignored and the peer is used as the client IP. If a proxy or router sits in front, add its address to the trusted list below.</div>
{{end}}
</div>
<div class="mb-3">
<div class="form-label">X-Forwarded-For chain <span class="text-secondary">(client = rightmost untrusted hop)</span></div>
{{if .XFFChain}}
<div class="d-flex flex-wrap gap-2">
{{range .XFFChain}}
<span class="badge font-monospace {{if .Selected}}bg-success-lt{{else if .Trusted}}bg-secondary-lt{{else}}bg-azure-lt{{end}}">{{.IP}}{{if .Trusted}} · trusted{{end}}{{if .Selected}} · client{{end}}</span>
{{end}}
</div>
{{else}}<span class="text-secondary">No X-Forwarded-For header on this request.</span>{{end}}
</div>
<div>
<div class="form-label">Trusted proxy ranges</div>
<div class="d-flex flex-wrap gap-2 mb-1">
{{range .TrustedProxies}}<code>{{.}}</code>{{end}}
</div>
<small class="form-hint">Set <code>MESHTENDER_TRUSTED_PROXIES</code> (comma-separated CIDRs/IPs, or the <code>private</code> shorthand) to trust additional hops. Loopback is always trusted.</small>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Forwarding headers</h3></div>
<div class="table-responsive">
+83
View File
@@ -0,0 +1,83 @@
package web
import (
"net"
"net/http"
"strings"
)
// resolveClientIP replaces chi's RealIP with trusted-proxy-aware resolution. It
// must run after CaptureRemoteAddr (which preserved the true TCP peer) and sets
// r.RemoteAddr to the resolved client IP so ClientIP and downstream code see it.
func (e *Env) resolveClientIP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.RemoteAddr = ClientIPFrom(
RawRemoteAddr(r),
r.Header.Get("X-Forwarded-For"),
r.Header.Get("X-Real-IP"),
e.Cfg.TrustedProxies,
)
next.ServeHTTP(w, r)
})
}
// ClientIPFrom resolves the real client IP from the connecting peer and the
// forwarding headers, honoring those headers only through trusted proxies:
//
// - If the peer is not a trusted proxy, the headers are ignored (they can be
// spoofed by a direct client) and the peer is returned.
// - Otherwise the X-Forwarded-For chain is walked right-to-left and the first
// entry that is not itself a trusted proxy is the client. (X-Real-IP is used
// as the chain when X-Forwarded-For is absent.)
// - If every hop is trusted (or there are no headers), the peer is returned.
func ClientIPFrom(peer, xff, xRealIP string, trusted []*net.IPNet) string {
peerHost := hostOnly(peer)
if !IsTrustedProxy(peerHost, trusted) {
return peerHost
}
chain := splitList(xff)
if len(chain) == 0 {
if x := strings.TrimSpace(xRealIP); x != "" {
chain = []string{x}
}
}
for i := len(chain) - 1; i >= 0; i-- {
if !IsTrustedProxy(chain[i], trusted) {
return chain[i]
}
}
return peerHost
}
// IsTrustedProxy reports whether ip (a bare address, no port) falls within any of
// the trusted CIDR ranges.
func IsTrustedProxy(ip string, trusted []*net.IPNet) bool {
parsed := net.ParseIP(strings.TrimSpace(ip))
if parsed == nil {
return false
}
for _, n := range trusted {
if n != nil && n.Contains(parsed) {
return true
}
}
return false
}
func hostOnly(addr string) string {
if h, _, err := net.SplitHostPort(addr); err == nil {
return h
}
return addr
}
// splitList splits a comma-separated header value, trimming blanks.
func splitList(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+2 -2
View File
@@ -198,8 +198,8 @@ func (rn *Renderer) Render(w http.ResponseWriter, r *http.Request, page string,
// auth-free. chi requires all Use() calls before any route is registered.
func (e *Env) CommonMiddleware(r chi.Router) {
r.Use(middleware.RequestID)
r.Use(CaptureRemoteAddr) // keep the true TCP peer before RealIP rewrites it
r.Use(middleware.RealIP)
r.Use(CaptureRemoteAddr) // preserve the true TCP peer before we resolve
r.Use(e.resolveClientIP) // trusted-proxy-aware X-Forwarded-For resolution
r.Use(middleware.Recoverer)
}
+68 -41
View File
@@ -1,57 +1,84 @@
package web
import (
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5/middleware"
)
// TestCaptureRemoteAddrPreservesPeer verifies the ordering that the proxy-test
// page relies on: CaptureRemoteAddr (which must run before RealIP) keeps the true
// TCP peer, while RealIP rewrites RemoteAddr from X-Forwarded-For so ClientIP
// reflects the header-derived client.
func TestCaptureRemoteAddrPreservesPeer(t *testing.T) {
var gotClient, gotPeer string
final := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
gotClient = ClientIP(r)
gotPeer = RawRemoteAddr(r)
})
// Same order as CommonMiddleware: capture, then RealIP.
h := CaptureRemoteAddr(middleware.RealIP(final))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:5555" // the proxy/load balancer
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
h.ServeHTTP(httptest.NewRecorder(), req)
if gotClient != "203.0.113.9" {
t.Errorf("ClientIP = %q, want the first X-Forwarded-For entry 203.0.113.9", gotClient)
func cidrs(t *testing.T, ss ...string) []*net.IPNet {
t.Helper()
out := make([]*net.IPNet, 0, len(ss))
for _, s := range ss {
_, n, err := net.ParseCIDR(s)
if err != nil {
t.Fatalf("bad CIDR %q: %v", s, err)
}
out = append(out, n)
}
if gotPeer != "10.0.0.1:5555" {
t.Errorf("RawRemoteAddr = %q, want the original peer 10.0.0.1:5555", gotPeer)
return out
}
func TestClientIPFrom(t *testing.T) {
loopback := cidrs(t, "127.0.0.0/8")
withRouter := cidrs(t, "127.0.0.0/8", "192.168.3.1/32")
cases := []struct {
name string
peer string
xff string
xRealIP string
trusted []*net.IPNet
want string
}{
{
name: "untrusted peer ignores headers (anti-spoof)",
peer: "203.0.113.9:443", xff: "1.2.3.4", trusted: loopback,
want: "203.0.113.9",
},
{
name: "local proxy + router in XFF, router NOT trusted -> logs router (the bug)",
peer: "127.0.0.1:5000", xff: "203.0.113.9, 192.168.3.1", trusted: loopback,
want: "192.168.3.1",
},
{
name: "local proxy + router trusted -> recovers real client",
peer: "127.0.0.1:5000", xff: "203.0.113.9, 192.168.3.1", trusted: withRouter,
want: "203.0.113.9",
},
{
name: "trusted peer, no XFF, X-Real-IP used",
peer: "127.0.0.1:5000", xRealIP: "203.0.113.9", trusted: loopback,
want: "203.0.113.9",
},
{
name: "trusted peer, all hops trusted -> falls back to peer",
peer: "127.0.0.1:5000", xff: "192.168.3.1", trusted: withRouter,
want: "127.0.0.1",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ClientIPFrom(tc.peer, tc.xff, tc.xRealIP, tc.trusted); got != tc.want {
t.Errorf("ClientIPFrom(peer=%q, xff=%q, xRealIP=%q) = %q, want %q",
tc.peer, tc.xff, tc.xRealIP, got, tc.want)
}
})
}
}
// TestCaptureRemoteAddrDirect: with no forwarding header, the recorded client IP
// is the peer itself (the "no trusted proxy" case the page warns about).
func TestCaptureRemoteAddrDirect(t *testing.T) {
var gotClient, gotPeer string
final := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
gotClient = ClientIP(r)
gotPeer = RawRemoteAddr(r)
})
h := CaptureRemoteAddr(middleware.RealIP(final))
// TestCaptureRemoteAddr verifies the middleware preserves the true TCP peer in
// the context so RawRemoteAddr can report it after resolution.
func TestCaptureRemoteAddr(t *testing.T) {
var got string
h := CaptureRemoteAddr(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
got = RawRemoteAddr(r)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "198.51.100.7:40000"
req.RemoteAddr = "10.0.0.1:5555"
h.ServeHTTP(httptest.NewRecorder(), req)
if gotClient != "198.51.100.7" {
t.Errorf("ClientIP = %q, want peer host 198.51.100.7", gotClient)
}
if gotPeer != "198.51.100.7:40000" {
t.Errorf("RawRemoteAddr = %q, want 198.51.100.7:40000", gotPeer)
if got != "10.0.0.1:5555" {
t.Errorf("RawRemoteAddr = %q, want 10.0.0.1:5555", got)
}
}