Files
2026-08-09 09:33:47 -04:00

119 lines
3.5 KiB
Go

package core
import (
"net"
"net/http"
"sort"
"strings"
"github.com/MeshTender/MeshTender/internal/web"
)
// proxyHeaders are the forwarding-related headers worth surfacing first when
// diagnosing how the reverse proxy presents the client to the app.
var proxyHeaders = []string{
"X-Forwarded-For",
"X-Forwarded-Proto",
"X-Forwarded-Host",
"X-Forwarded-Port",
"X-Real-IP",
"Forwarded",
"Via",
"CF-Connecting-IP",
"True-Client-IP",
"Fastly-Client-IP",
"Fly-Client-IP",
"X-Cluster-Client-IP",
}
// redactedHeaders carry credentials and are masked in the full dump, since this
// page is a prime candidate to be screenshotted while debugging.
var redactedHeaders = map[string]bool{
"Cookie": true,
"Authorization": true,
"Proxy-Authorization": true,
}
type headerKV struct {
Name string
Value string
}
// pageProxyTest dumps the request details that determine the client IP the app
// records, so an admin can confirm the reverse proxy is configured correctly.
// chi's RealIP middleware has already rewritten RemoteAddr from the forwarding
// headers by the time this runs; CaptureRemoteAddr preserved the true TCP peer.
func (s *Handlers) pageProxyTest(w http.ResponseWriter, r *http.Request) {
resolved := web.ClientIP(r) // what rate-limiting and audit logs record
rawPeer := web.RawRemoteAddr(r) // the actual connecting socket
rawHost := rawPeer
if h, _, err := net.SplitHostPort(rawPeer); err == nil {
rawHost = h
}
// Curated forwarding headers (always shown, blanks included so a missing one
// is visible).
forwarding := make([]headerKV, 0, len(proxyHeaders))
for _, n := range proxyHeaders {
forwarding = append(forwarding, headerKV{Name: n, Value: r.Header.Get(n)})
}
// Full header dump, sorted, with credentials redacted.
var all []headerKV
for name, vals := range r.Header {
v := strings.Join(vals, ", ")
if redactedHeaders[http.CanonicalHeaderKey(name)] {
v = "(redacted)"
}
all = append(all, headerKV{Name: name, Value: v})
}
sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name })
scheme := "http"
if r.TLS != nil {
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,
"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
// isn't sending forwarding headers (you'd be logging the proxy's IP).
"HeaderApplied": rawHost != resolved,
"Forwarding": forwarding,
"AllHeaders": all,
"Host": r.Host,
"Proto": r.Proto,
"Scheme": scheme,
"Method": r.Method,
"RequestURI": r.RequestURI,
})
}