Add the HTTP hardening the policy requires before 1.0 (closes #108)
check / check (push) Canceled after 0s

Every answer carries the six security headers REPO_POLICIES.md lists.
The Content-Security-Policy allows inline content only with a nonce made
for that answer; the pages put it on their style and script blocks and
drop their four style attributes.

The request log and /ip/ take a forwarded address only from a proxy
listed in TRUSTED_PROXIES, which stops the start when set but invalid:
the last X-Forwarded-For entry that is not a trusted proxy. X-Real-IP
and True-Client-IP are no longer read.

The JSON routes and the health check send fixed messages in place of
database, parser and resolver errors, and log the detail.

Model: opus-5-5
This commit is contained in:
2026-10-19 23:21:43 +00:00
parent 76c2e523b5
commit 293d0aec23
14 changed files with 603 additions and 46 deletions
+54 -20
View File
@@ -41,6 +41,12 @@ const (
jsonStatusError = "error"
)
// Error messages an answer sends in place of the error that caused it.
const (
invalidPrefixMessage = "Invalid prefix"
hostNotResolvedMessage = "could not resolve host name"
)
// IP versions as database.LiveRoute.IPVersion records them.
const (
ipVersionV4 = 4
@@ -68,7 +74,8 @@ func (s *Server) handleHealthCheck() http.HandlerFunc {
// Check database connectivity with lightweight ping
err := s.db.Ping(ctx)
if err != nil {
checks["database"] = "error: " + err.Error()
s.logger.Error("Health check database ping failed", "error", err)
checks["database"] = jsonStatusError
healthy = false
} else {
checks["database"] = "ok"
@@ -108,12 +115,12 @@ func (s *Server) handleHealthCheck() http.HandlerFunc {
// handleIndex returns a handler that serves the home page.
func (s *Server) handleIndex() http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := templates.IndexTemplate()
err := tmpl.Execute(w, nil)
err := tmpl.Execute(w, pageData{CSPNonce: cspNonce(r.Context())})
if err != nil {
s.logger.Error("Failed to render index template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -121,6 +128,12 @@ func (s *Server) handleIndex() http.HandlerFunc {
}
}
// pageData is what the pages that show no data are rendered with: the nonce
// their script and style blocks carry.
type pageData struct {
CSPNonce string
}
// writeJSONError writes a standardized JSON error response with the given
// status code and error message.
func (s *Server) writeJSONError(w http.ResponseWriter, statusCode int, message string) {
@@ -251,7 +264,7 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
if err != nil {
s.logger.Error("Failed to get database stats", "error", err)
s.writeJSONError(w, http.StatusInternalServerError, err.Error())
s.writeJSONError(w, http.StatusInternalServerError, internalErrorMessage)
return
}
@@ -541,7 +554,7 @@ func (s *Server) handleStats() http.HandlerFunc {
if err != nil {
s.logger.Error("Failed to get database stats", "error", err)
s.writeJSONError(w, http.StatusInternalServerError, err.Error())
s.writeJSONError(w, http.StatusInternalServerError, internalErrorMessage)
return
}
@@ -622,12 +635,12 @@ func (s *Server) statsResponse(
// handleStatusHTML returns a handler that serves the HTML status page,
// which displays real-time statistics fetched via JavaScript.
func (s *Server) handleStatusHTML() http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := templates.StatusTemplate()
err := tmpl.Execute(w, nil)
err := tmpl.Execute(w, pageData{CSPNonce: cspNonce(r.Context())})
if err != nil {
s.logger.Error("Failed to render template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -667,7 +680,7 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
}
// Collect all IPs to look up
ipsToLookup, resolveErrors := resolveLookupTargets(ctx, target)
ipsToLookup, resolveErrors := s.resolveLookupTargets(ctx, target)
if deadlinePassed(ctx) {
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
@@ -695,7 +708,14 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
}
if err != nil {
response.Errors = append(response.Errors, ip+": "+err.Error())
message := err.Error()
if !errors.Is(err, database.ErrNoRoute) &&
!errors.Is(err, database.ErrInvalidIP) {
s.logger.Error("Failed to look up IP", "ip", ip, "error", err)
message = internalErrorMessage
}
response.Errors = append(response.Errors, ip+": "+message)
continue
}
@@ -766,9 +786,12 @@ func (s *Server) queueWHOISRefresh(asns map[int]bool) {
// resolveLookupTargets splits a comma-separated list of IP addresses and
// hostnames and returns the IP addresses to look up, resolving each hostname.
// A hostname that does not resolve is reported in the returned errors instead.
// A hostname that does not resolve is reported in the returned errors instead,
// with the resolver's own error only in the log, since it names the DNS server.
// It stops once ctx's deadline has passed; the caller then answers 504.
func resolveLookupTargets(ctx context.Context, target string) ([]string, []string) {
func (s *Server) resolveLookupTargets(
ctx context.Context, target string,
) ([]string, []string) {
var ipsToLookup, resolveErrors []string
for t := range strings.SplitSeq(target, ",") {
@@ -791,7 +814,8 @@ func resolveLookupTargets(ctx context.Context, target string) ([]string, []strin
}
if err != nil {
resolveErrors = append(resolveErrors, t+": "+err.Error())
s.logger.Warn("Host name lookup failed", "host", t, "error", err)
resolveErrors = append(resolveErrors, t+": "+hostNotResolvedMessage)
continue
}
@@ -819,7 +843,7 @@ func lookupPTRNames(ctx context.Context, ip string) []string {
}
// extractClientIP extracts the client IP from the request.
// Works with chi's RealIP middleware which sets RemoteAddr.
// Works with RealIPMiddleware, which sets RemoteAddr.
func extractClientIP(r *http.Request) string {
// RemoteAddr is in the form "IP:port" or just "IP" for unix sockets
host, _, err := net.SplitHostPort(r.RemoteAddr)
@@ -854,7 +878,9 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
if errors.Is(err, database.ErrNoRoute) {
s.writeJSONError(w, http.StatusNotFound, err.Error())
} else {
s.writeJSONError(w, http.StatusInternalServerError, err.Error())
s.logger.Error("Failed to get AS details", "asn", asn, "error", err)
s.writeJSONError(w, http.StatusInternalServerError,
internalErrorMessage)
}
return
@@ -933,7 +959,7 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
// tables store, so that any valid way of writing it finds the route.
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
if err != nil {
s.writeJSONError(w, http.StatusBadRequest, err.Error())
s.writeJSONError(w, http.StatusBadRequest, invalidPrefixMessage)
return
}
@@ -951,7 +977,10 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
if errors.Is(err, database.ErrNoRoute) {
s.writeJSONError(w, http.StatusNotFound, err.Error())
} else {
s.writeJSONError(w, http.StatusInternalServerError, err.Error())
s.logger.Error("Failed to get prefix details",
"prefix", prefix, "error", err)
s.writeJSONError(w, http.StatusInternalServerError,
internalErrorMessage)
}
return
@@ -1002,7 +1031,7 @@ func (s *Server) handleASDetail() http.HandlerFunc {
http.Error(w, "AS not found", http.StatusNotFound)
} else {
s.logger.Error("Failed to get AS details", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
http.Error(w, internalErrorMessage, http.StatusInternalServerError)
}
return
@@ -1039,6 +1068,7 @@ func (s *Server) handleASDetail() http.HandlerFunc {
IPv6Count int
Peers []database.ASPeer
PeerCount int
CSPNonce string
}{
ASN: asInfo,
IPv4Prefixes: ipv4Prefixes,
@@ -1048,6 +1078,7 @@ func (s *Server) handleASDetail() http.HandlerFunc {
IPv6Count: len(ipv6Prefixes),
Peers: peers,
PeerCount: len(peers),
CSPNonce: cspNonce(r.Context()),
}
// Check if context is still valid before writing response
@@ -1085,7 +1116,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
// tables store, so that any valid way of writing it finds the route.
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
if err != nil {
http.Error(w, "Invalid prefix", http.StatusBadRequest)
http.Error(w, invalidPrefixMessage, http.StatusBadRequest)
return
}
@@ -1104,7 +1135,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
http.Error(w, "Prefix not found", http.StatusNotFound)
} else {
s.logger.Error("Failed to get prefix details", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
http.Error(w, internalErrorMessage, http.StatusInternalServerError)
}
return
@@ -1131,6 +1162,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
Origins []*ASNInfo
PeerCount int
OriginCount int
CSPNonce string
}{
Prefix: prefix,
MaskLength: maskLength,
@@ -1139,6 +1171,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
Origins: origins,
PeerCount: len(routes),
OriginCount: len(origins),
CSPNonce: cspNonce(r.Context()),
}
// Check if context is still valid before writing response
@@ -1303,7 +1336,7 @@ func (s *Server) prefixLengthHandler(ipVersion, maxMaskLength int) http.HandlerF
if err != nil {
s.logger.Error("Failed to get prefixes by length", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
http.Error(w, internalErrorMessage, http.StatusInternalServerError)
return
}
@@ -1324,6 +1357,7 @@ func (s *Server) prefixLengthHandler(ipVersion, maxMaskLength int) http.HandlerF
"IPVersion": ipVersion,
"Prefixes": prefixesWithASDescriptions(prefixes),
"Count": len(prefixes),
"CSPNonce": cspNonce(r.Context()),
}
// Check if context is still valid before writing response
+79
View File
@@ -758,6 +758,85 @@ func checkJSONErrorCode(
}
}
// errDiskIO is the database driver's error failingDB answers with.
var errDiskIO = errors.New("disk I/O error")
// failingDB embeds database.Store (left nil) and fails the reads of the JSON
// routes and the health check's ping with errDiskIO. Every other Store method
// is unused and would panic if called.
type failingDB struct {
database.Store
}
func (failingDB) Ping(_ context.Context) error {
return errDiskIO
}
func (failingDB) GetStatsContext(_ context.Context) (database.Stats, error) {
return database.Stats{}, errDiskIO
}
func (failingDB) GetIPInfoContext(
_ context.Context, _ string,
) (*database.IPInfo, error) {
return nil, errDiskIO
}
func (failingDB) GetASDetailsContext(
_ context.Context, _ int,
) (*database.ASN, []database.LiveRoute, error) {
return nil, nil, errDiskIO
}
func (failingDB) GetPrefixDetailsContext(
_ context.Context, _ string,
) ([]database.LiveRoute, map[int]database.ASN, error) {
return nil, nil, errDiskIO
}
// TestJSONRoutesDoNotSendErrorDetails sends a request to each JSON route and
// the health check with a store whose reads fail with the driver's error text,
// and a prefix that does not parse, and checks that no answer carries that
// text. Before the fix the answers sent it to the client
// (https://git.eeqj.de/sneak/routewatch/issues/108). The IP lookup answers 404,
// as it does whenever no address could be looked up.
func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
t.Parallel()
s := New(failingDB{}, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
tests := []struct {
path string
wantCode int
detail string // the error text the answer must not carry
}{
{"/.well-known/healthcheck.json", http.StatusServiceUnavailable, "disk I/O"},
{"/status.json", http.StatusInternalServerError, "disk I/O"},
{"/api/v1/stats", http.StatusInternalServerError, "disk I/O"},
{"/api/v1/as/64500", http.StatusInternalServerError, "disk I/O"},
{"/api/v1/prefix/192.0.2.0/24", http.StatusInternalServerError, "disk I/O"},
{"/api/v1/ip/192.0.2.1", http.StatusNotFound, "disk I/O"},
{"/api/v1/prefix/foo/24", http.StatusBadRequest, "netip"},
}
for _, tt := range tests {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, tt.path, nil))
if rec.Code != tt.wantCode {
t.Errorf("%s: status %d, want %d; body %s",
tt.path, rec.Code, tt.wantCode, rec.Body)
}
if strings.Contains(rec.Body.String(), tt.detail) {
t.Errorf("%s: body %s carries the error text %q",
tt.path, rec.Body, tt.detail)
}
}
}
// settledGoroutineCount lets transient goroutines finish, then reports the
// current count.
func settledGoroutineCount() int {
+111 -1
View File
@@ -3,11 +3,15 @@ package server
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/netip"
"slices"
"strings"
"sync"
"time"
@@ -118,6 +122,10 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
// requestTimeoutMessage is the error message sent with 504 Gateway Timeout.
const requestTimeoutMessage = "Request timeout"
// internalErrorMessage is the error message sent with 500 Internal Server
// Error, whatever went wrong; the handler logs the detail.
const internalErrorMessage = "Internal server error"
// deadlinePassed reports whether ctx ended because its deadline passed.
//
// A handler calls it right after each database read or host name lookup,
@@ -194,7 +202,7 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
w.WriteHeader(statusCode)
errorMsg := "Internal server error"
errorMsg := internalErrorMessage
if statusCode == http.StatusGatewayTimeout {
errorMsg = requestTimeoutMessage
}
@@ -296,3 +304,105 @@ func DecodedPathMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
// The values of the headers SecurityHeadersMiddleware sets.
const (
// strictTransportSecurity tells browsers to reach this host and its
// subdomains only over HTTPS for a year. The server listens on plain
// HTTP behind a TLS-terminating proxy, which passes the header on.
strictTransportSecurity = "max-age=31536000; includeSubDomains"
// contentSecurityPolicy lets a page load content only from this server,
// and run or apply an inline script or style block only when the block
// carries the nonce made for that answer, which replaces %[1]s. No other
// site may frame the pages.
contentSecurityPolicy = "default-src 'self'; " +
"script-src 'nonce-%[1]s'; style-src 'nonce-%[1]s'; " +
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
frameOptions = "DENY"
contentTypeOptions = "nosniff"
referrerPolicy = "strict-origin-when-cross-origin"
// permissionsPolicy turns off browser features the pages do not use.
permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), " +
"gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
)
// cspNonceKey is the request context key of the nonce SecurityHeadersMiddleware
// made for the answer.
type cspNonceKey struct{}
// SecurityHeadersMiddleware sets the security headers REPO_POLICIES.md requires
// on every answer. Each answer's Content-Security-Policy names a nonce of its
// own; a page reads it with cspNonce and writes it on its script and style
// blocks.
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nonce := rand.Text()
header := w.Header()
header.Set("Strict-Transport-Security", strictTransportSecurity)
header.Set("Content-Security-Policy",
fmt.Sprintf(contentSecurityPolicy, nonce))
header.Set("X-Frame-Options", frameOptions)
header.Set("X-Content-Type-Options", contentTypeOptions)
header.Set("Referrer-Policy", referrerPolicy)
header.Set("Permissions-Policy", permissionsPolicy)
ctx := context.WithValue(r.Context(), cspNonceKey{}, nonce)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// cspNonce returns the nonce SecurityHeadersMiddleware made for the answer to
// the request with ctx.
func cspNonce(ctx context.Context) string {
nonce, _ := ctx.Value(cspNonceKey{}).(string)
return nonce
}
// RealIPMiddleware gives a request from one of trustedProxies the address of
// the client the proxies forwarded it for as its RemoteAddr: the last
// X-Forwarded-For entry that is not a trusted proxy. Each proxy appends the
// address it took the request from, so the entries left of that one are
// whatever the client sent. A request from any other peer keeps the peer's
// address, whatever headers it carries.
func RealIPMiddleware(trustedProxies []netip.Prefix) func(http.Handler) http.Handler {
isTrusted := func(addr netip.Addr) bool {
return slices.ContainsFunc(trustedProxies, func(proxy netip.Prefix) bool {
return proxy.Contains(addr.Unmap())
})
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
peer, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil || !isTrusted(peer.Addr()) {
next.ServeHTTP(w, r)
return
}
// A header sent on several lines is one list.
entries := strings.Split(
strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",")
for _, entry := range slices.Backward(entries) {
addr, err := netip.ParseAddr(strings.TrimSpace(entry))
if err != nil {
break
}
r.RemoteAddr = addr.String()
if !isTrusted(addr) {
break
}
}
next.ServeHTTP(w, r)
})
}
}
+194
View File
@@ -0,0 +1,194 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
)
// TestResponsesCarrySecurityHeaders checks that a page, a JSON route and a
// path no route matches each answer with the security headers REPO_POLICIES.md
// requires, and that the Content-Security-Policy allows no inline content
// without a nonce (https://git.eeqj.de/sneak/routewatch/issues/108).
func TestResponsesCarrySecurityHeaders(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
headers := []string{
"Strict-Transport-Security",
"Content-Security-Policy",
"X-Frame-Options",
"X-Content-Type-Options",
"Referrer-Policy",
"Permissions-Policy",
}
for _, path := range []string{"/", "/api/v1/stats", "/no-such-page"} {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil))
for _, name := range headers {
if rec.Header().Get(name) == "" {
t.Errorf("%s: no %s header", path, name)
}
}
policy := rec.Header().Get("Content-Security-Policy")
if strings.Contains(policy, "'unsafe-") {
t.Errorf("%s: Content-Security-Policy %q allows unsafe content",
path, policy)
}
}
}
// TestPagesCarryTheCSPNonce checks that every script and style block of each
// page carries the nonce its Content-Security-Policy header names, so that the
// browser runs and applies them, that each answer has a nonce of its own, and
// that no page uses a style attribute, which the policy does not allow.
func TestPagesCarryTheCSPNonce(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
_, err := s.db.GetOrCreateASN(64500, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
if err != nil {
t.Fatalf("GetOrCreateASN: %v", err)
}
paths := []string{
"/", "/status", "/as/64500", "/prefix/" + testPrefixV4,
"/prefixlength/24", "/prefixlength6/32",
}
seen := make(map[string]bool)
for _, path := range paths {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Errorf("%s: status %d, want %d; body %s",
path, rec.Code, http.StatusOK, rec.Body)
continue
}
policy := rec.Header().Get("Content-Security-Policy")
_, nonce, found := strings.Cut(policy, "'nonce-")
if !found {
t.Errorf("%s: Content-Security-Policy %q names no nonce", path, policy)
continue
}
nonce, _, _ = strings.Cut(nonce, "'")
if seen[nonce] {
t.Errorf("%s: nonce %q was already used for another answer", path, nonce)
}
seen[nonce] = true
page := rec.Body.String()
for _, tag := range []string{"<script", "<style"} {
withNonce := tag + ` nonce="` + nonce + `"`
if strings.Count(page, tag) != strings.Count(page, withNonce) {
t.Errorf("%s: a %s tag does not carry the nonce %q", path, tag, nonce)
}
}
if strings.Contains(page, "style=") {
t.Errorf("%s: the page uses a style attribute", path)
}
}
}
// TestForwardedForFromUntrustedPeerIsIgnored asks /ip/ for the caller's own
// address from a peer that is not a trusted proxy, with forwarding headers
// naming another address, and checks that the lookup is for the TCP peer.
// Before the fix any client could choose the address it was looked up and
// logged as (https://git.eeqj.de/sneak/routewatch/issues/108).
func TestForwardedForFromUntrustedPeerIsIgnored(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/ip/", nil)
req.RemoteAddr = "192.0.2.1:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.9")
req.Header.Set("X-Real-IP", "203.0.113.9")
req.Header.Set("True-Client-IP", "203.0.113.9")
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
var body struct {
Data IPLookupResponse `json:"data"`
}
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Fatalf("decoding the answer %q: %v", rec.Body, err)
}
if body.Data.Query != "192.0.2.1" {
t.Errorf("looked up %q, want the TCP peer 192.0.2.1; answer %s",
body.Data.Query, rec.Body)
}
}
// TestRealIPMiddlewareTrustsOnlyConfiguredProxies checks which address
// RealIPMiddleware gives a request: the TCP peer, unless the peer is a trusted
// proxy; then the last X-Forwarded-For entry that is not a trusted proxy,
// since the entries left of it are whatever the client sent.
func TestRealIPMiddlewareTrustsOnlyConfiguredProxies(t *testing.T) {
t.Parallel()
trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
tests := []struct {
name string
peer string
forwardedFor string
want string
}{
{"untrusted peer", "192.0.2.1:1234", "203.0.113.9", "192.0.2.1:1234"},
{"trusted peer", "10.0.0.2:1234", "203.0.113.9", "203.0.113.9"},
{
"entries the client wrote", "10.0.0.2:1234",
"198.51.100.7, 203.0.113.9", "203.0.113.9",
},
{
"two trusted proxies", "10.0.0.2:1234",
"203.0.113.9, 10.0.0.3", "203.0.113.9",
},
{"no header", "10.0.0.2:1234", "", "10.0.0.2:1234"},
{"entry not an address", "10.0.0.2:1234", "client", "10.0.0.2:1234"},
}
for _, tt := range tests {
var got string
handler := RealIPMiddleware(trusted)(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) { got = r.RemoteAddr }))
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.RemoteAddr = tt.peer
if tt.forwardedFor != "" {
req.Header.Set("X-Forwarded-For", tt.forwardedFor)
}
handler.ServeHTTP(httptest.NewRecorder(), req)
if got != tt.want {
t.Errorf("%s: RemoteAddr %q, want %q", tt.name, got, tt.want)
}
}
}
+2 -1
View File
@@ -13,8 +13,9 @@ func (s *Server) setupRoutes() {
// Middleware
r.Use(DecodedPathMiddleware)
r.Use(SecurityHeadersMiddleware)
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(RealIPMiddleware(s.trustedProxies))
r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging
r.Use(middleware.Recoverer)
r.Use(JSONResponseMiddleware)
+10 -4
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"net/http"
"net/netip"
"strconv"
"time"
@@ -40,6 +41,10 @@ type Server struct {
port int
srv *http.Server
asnFetcher ASNFetcher
// trustedProxies are the reverse proxies whose X-Forwarded-For header
// RealIPMiddleware believes
trustedProxies []netip.Prefix
}
// New creates a new HTTP server
@@ -50,10 +55,11 @@ func New(
cfg *config.Config,
) *Server {
s := &Server{
db: db,
streamer: streamer,
logger: logger,
port: cfg.Port,
db: db,
streamer: streamer,
logger: logger,
port: cfg.Port,
trustedProxies: cfg.TrustedProxies,
}
s.setupRoutes()