Skip to content

Commit c4ba5ae

Browse files
authored
web: add XFF debug endpoint and useful defaults for Server.TrustedProxies (#112)
1 parent b6f523e commit c4ba5ae

4 files changed

Lines changed: 149 additions & 3 deletions

File tree

web/debug.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ import (
1212
_ "embed"
1313
"html"
1414
"html/template"
15+
"net"
1516
"net/http"
1617
"net/http/pprof"
18+
"net/netip"
1719
"net/url"
1820
"os"
1921
"runtime"
2022
"slices"
23+
"strings"
2124
"sync"
2225
"time"
2326

@@ -119,6 +122,88 @@ var timeStart = time.Now()
119122

120123
func uptime() time.Duration { return time.Since(timeStart).Round(time.Second) }
121124

125+
type xffDebugPart struct {
126+
Raw string `json:"raw"`
127+
Parsed string `json:"parsed,omitempty"`
128+
Valid bool `json:"valid"`
129+
}
130+
131+
type xffDebugResponse struct {
132+
RemoteAddr string `json:"remote_addr"`
133+
RemoteHost string `json:"remote_host"`
134+
RemoteAddrParsed bool `json:"remote_addr_parsed"`
135+
RemoteAddrIP string `json:"remote_addr_ip,omitempty"`
136+
137+
ConnNetwork string `json:"conn_network,omitempty"`
138+
ListenerNetwork string `json:"listener_network,omitempty"`
139+
140+
UsingDefaultTrustedProxies bool `json:"using_default_trusted_proxies"`
141+
TrustedForwardedSource bool `json:"trusted_forwarded_source"`
142+
TrustedProxies []string `json:"trusted_proxies"`
143+
144+
XForwardedForRaw string `json:"x_forwarded_for_raw"`
145+
XForwardedForParts []xffDebugPart `json:"x_forwarded_for_parts"`
146+
XRealIP string `json:"x_real_ip,omitempty"`
147+
Forwarded string `json:"forwarded,omitempty"`
148+
149+
RealIPResult string `json:"real_ip_result"`
150+
}
151+
152+
func (s *Server) xffDebugHandler() http.Handler {
153+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
154+
host, _, err := net.SplitHostPort(r.RemoteAddr)
155+
if err != nil {
156+
host = r.RemoteAddr
157+
}
158+
host = strings.TrimSpace(host)
159+
160+
addr, parseErr := netip.ParseAddr(host)
161+
hasAddr := parseErr == nil
162+
163+
var trustedProxies []string
164+
for _, prefix := range s.trustedProxies() {
165+
trustedProxies = append(trustedProxies, prefix.String())
166+
}
167+
168+
var parts []xffDebugPart
169+
for part := range strings.SplitSeq(r.Header.Get("X-Forwarded-For"), ",") {
170+
raw := strings.TrimSpace(part)
171+
if raw == "" {
172+
continue
173+
}
174+
175+
item := xffDebugPart{Raw: raw}
176+
if parsed, err := netip.ParseAddr(raw); err == nil {
177+
item.Valid = true
178+
item.Parsed = parsed.String()
179+
}
180+
parts = append(parts, item)
181+
}
182+
183+
connNetwork, _ := r.Context().Value(connNetworkContextKey).(string)
184+
resp := xffDebugResponse{
185+
RemoteAddr: r.RemoteAddr,
186+
RemoteHost: host,
187+
RemoteAddrParsed: hasAddr,
188+
ConnNetwork: connNetwork,
189+
ListenerNetwork: s.listenerNetwork,
190+
UsingDefaultTrustedProxies: s.TrustedProxies == nil,
191+
TrustedForwardedSource: s.isTrustedForwardedSource(r, hasAddr, addr),
192+
TrustedProxies: trustedProxies,
193+
XForwardedForRaw: r.Header.Get("X-Forwarded-For"),
194+
XForwardedForParts: parts,
195+
XRealIP: r.Header.Get("X-Real-IP"),
196+
Forwarded: r.Header.Get("Forwarded"),
197+
RealIPResult: s.realIP(r),
198+
}
199+
if hasAddr {
200+
resp.RemoteAddrIP = addr.String()
201+
}
202+
203+
RespondJSON(w, resp)
204+
})
205+
}
206+
122207
// ServeHTTP implements the [http.Handler] interface.
123208
func (d *DebugHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
124209
if r.URL.Path != "/debug/" {

web/debug_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"encoding/json"
1010
"fmt"
1111
"net/http"
12+
"net/http/httptest"
13+
"net/netip"
1214
"os"
1315
"strings"
1416
"testing"
@@ -158,6 +160,47 @@ func TestDebuggerDiscovery(t *testing.T) {
158160
}
159161
}
160162

163+
func TestXFFDebugHandler(t *testing.T) {
164+
t.Parallel()
165+
166+
s := &Server{}
167+
req := httptest.NewRequest(http.MethodGet, "/debug/xff", nil)
168+
req.RemoteAddr = "127.0.0.1:1234"
169+
req.Header.Set("X-Forwarded-For", " 203.0.113.9 , invalid")
170+
req.Header.Set("X-Real-IP", "198.51.100.10")
171+
req.Header.Set("Forwarded", "for=203.0.113.9")
172+
173+
w := httptest.NewRecorder()
174+
s.xffDebugHandler().ServeHTTP(w, req)
175+
176+
var got xffDebugResponse
177+
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
178+
t.Fatalf("failed to unmarshal xff debug response: %v\nbody: %s", err, w.Body.String())
179+
}
180+
181+
testutil.AssertEqual(t, true, got.UsingDefaultTrustedProxies)
182+
testutil.AssertEqual(t, true, got.TrustedForwardedSource)
183+
testutil.AssertEqual(t, []string{"127.0.0.0/8"}, got.TrustedProxies)
184+
testutil.AssertEqual(t, "203.0.113.9", got.RealIPResult)
185+
testutil.AssertEqual(t, "198.51.100.10", got.XRealIP)
186+
testutil.AssertEqual(t, "for=203.0.113.9", got.Forwarded)
187+
if len(got.XForwardedForParts) != 2 {
188+
t.Fatalf("got %d XFF parts, want 2: %+v", len(got.XForwardedForParts), got.XForwardedForParts)
189+
}
190+
testutil.AssertEqual(t, true, got.XForwardedForParts[0].Valid)
191+
testutil.AssertEqual(t, false, got.XForwardedForParts[1].Valid)
192+
193+
s.TrustedProxies = []netip.Prefix{}
194+
w = httptest.NewRecorder()
195+
s.xffDebugHandler().ServeHTTP(w, req)
196+
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
197+
t.Fatalf("failed to unmarshal xff debug response: %v\nbody: %s", err, w.Body.String())
198+
}
199+
testutil.AssertEqual(t, false, got.UsingDefaultTrustedProxies)
200+
testutil.AssertEqual(t, false, got.TrustedForwardedSource)
201+
testutil.AssertEqual(t, "127.0.0.1", got.RealIPResult)
202+
}
203+
161204
func getDebug(t *testing.T, mux *http.ServeMux) string {
162205
return send(t, mux, http.MethodGet, "/debug/", http.StatusOK)
163206
}

web/server.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ type Server struct {
7272
// Also, the server will start the systemd watchdog timer if enabled.
7373
NotifySystemd bool
7474
// TrustedProxies is a list of proxy CIDR ranges trusted to provide X-Forwarded-For.
75-
// If empty, X-Forwarded-For is ignored.
75+
// If nil, 127.0.0.0/8 is trusted by default. If empty but non-nil,
76+
// X-Forwarded-For is ignored.
7677
TrustedProxies []netip.Prefix
7778

7879
handler syncx.Lazy[*handler]
@@ -198,14 +199,25 @@ func (s *Server) realIP(r *http.Request) string {
198199
}
199200

200201
func (s *Server) isTrustedProxy(addr netip.Addr) bool {
201-
for _, prefix := range s.TrustedProxies {
202+
for _, prefix := range s.trustedProxies() {
202203
if prefix.Contains(addr) {
203204
return true
204205
}
205206
}
206207
return false
207208
}
208209

210+
var defaultTrustedProxies = []netip.Prefix{
211+
netip.MustParsePrefix("127.0.0.0/8"),
212+
}
213+
214+
func (s *Server) trustedProxies() []netip.Prefix {
215+
if s.TrustedProxies == nil {
216+
return defaultTrustedProxies
217+
}
218+
return s.TrustedProxies
219+
}
220+
209221
func (s *Server) isTrustedForwardedSource(r *http.Request, hasAddr bool, addr netip.Addr) bool {
210222
if network, _ := r.Context().Value(connNetworkContextKey).(string); network == "unix" {
211223
return true
@@ -271,7 +283,7 @@ func (s *Server) initHandler() *handler {
271283

272284
s.Mux.Handle("GET /static/", hashfs.FileServer(h.static))
273285
if s.Debuggable {
274-
Debugger(s.Mux)
286+
Debugger(s.Mux).Handle("xff", "X-Forwarded-For", s.xffDebugHandler())
275287
}
276288

277289
if s.CrossOriginProtection != nil {

web/server_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,12 @@ func TestServerRealIP(t *testing.T) {
224224
req.Header.Set("X-Forwarded-For", "203.0.113.9")
225225
testutil.AssertEqual(t, "198.51.100.10", s.realIP(req))
226226

227+
req.RemoteAddr = "127.0.0.1:1234"
228+
testutil.AssertEqual(t, "203.0.113.9", s.realIP(req))
229+
230+
s.TrustedProxies = []netip.Prefix{}
231+
testutil.AssertEqual(t, "127.0.0.1", s.realIP(req))
232+
227233
prefix := netip.MustParsePrefix("192.0.2.0/24")
228234
s.TrustedProxies = []netip.Prefix{prefix}
229235
req.RemoteAddr = "192.0.2.10:9999"

0 commit comments

Comments
 (0)