check / check (push) Canceled after 0s
With TRUSTED_PROXIES unset, the server now trusts reverse proxies in the private ranges of RFC 1918. A proxy such as traefik on a Docker network is then believed. /ip/, the home page's curl card and the request log give the client's address from X-Forwarded-For instead of the proxy's address. A list in TRUSTED_PROXIES replaces the default. TRUSTED_PROXIES set but empty trusts no proxy, so the config package reads it with os.LookupEnv. A direct client at a public address still cannot choose its address with a forged header. Model: opus-5-5
383 lines
11 KiB
Go
383 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
|
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
|
)
|
|
|
|
// The addresses the forwarding tests use: the client a proxy names in
|
|
// X-Forwarded-For, the range of trusted proxies, a peer inside it, and a peer
|
|
// outside it, which lies in testPrefixV4 so that an IP lookup of it finds a
|
|
// route. reverseProxyPeer is a reverse proxy at a private address, as Docker
|
|
// gives one.
|
|
const (
|
|
forwardedClient = "203.0.113.9"
|
|
trustedProxyRange = "10.0.0.0/8"
|
|
trustedPeer = "10.0.0.2:1234"
|
|
untrustedPeerAddr = "192.0.2.1"
|
|
untrustedPeer = untrustedPeerAddr + ":1234"
|
|
reverseProxyPeer = "172.19.0.14:1234"
|
|
)
|
|
|
|
// 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{"/", statsPath, "/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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestJSONValidationKeeps4xxWithoutBody wraps a handler that answers 400
|
|
// without a body in JSONValidationMiddleware and checks that the JSON error
|
|
// body keeps the 400 and gives the status's own text. Before the fix it said
|
|
// "Internal server error"
|
|
// (https://git.eeqj.de/sneak/routewatch/issues/111).
|
|
func TestJSONValidationKeeps4xxWithoutBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
handler := JSONValidationMiddleware(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), http.MethodGet, "/", nil))
|
|
|
|
const name = "400 without a body"
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("%s: status %d, want %d; body %s",
|
|
name, rec.Code, http.StatusBadRequest, rec.Body)
|
|
}
|
|
|
|
checkJSONErrorCode(t, name, rec, http.StatusBadRequest)
|
|
checkJSONErrorMessage(t, name, rec, http.StatusText(http.StatusBadRequest))
|
|
}
|
|
|
|
// 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", asPagePath, "/prefix/" + testPrefixV4,
|
|
prefixLengthPagePath, "/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 = untrustedPeer
|
|
req.Header.Set("X-Forwarded-For", forwardedClient)
|
|
req.Header.Set("X-Real-IP", forwardedClient)
|
|
req.Header.Set("True-Client-IP", forwardedClient)
|
|
|
|
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 != untrustedPeerAddr {
|
|
t.Errorf("looked up %q, want the TCP peer %s; answer %s",
|
|
body.Data.Query, untrustedPeerAddr, rec.Body)
|
|
}
|
|
}
|
|
|
|
// TestDefaultTrustedProxiesGiveTheForwardedClient builds the server from the
|
|
// configuration with TRUSTED_PROXIES unset. A request from a reverse proxy at
|
|
// a private address must be looked up by /ip/, shown on the home page's card
|
|
// and logged as the client its X-Forwarded-For names. A direct request from a
|
|
// public address with a forged X-Forwarded-For keeps its own address. Before
|
|
// the fix no proxy was trusted unless TRUSTED_PROXIES named it, so /ip/ gave
|
|
// the proxy's address (https://git.eeqj.de/sneak/routewatch/issues/198).
|
|
func TestDefaultTrustedProxiesGiveTheForwardedClient(t *testing.T) {
|
|
t.Setenv("PORT", "")
|
|
t.Setenv("XDG_DATA_HOME", "")
|
|
|
|
// t.Setenv puts the variable back when the test ends.
|
|
t.Setenv("TRUSTED_PROXIES", "")
|
|
|
|
err := os.Unsetenv("TRUSTED_PROXIES")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := config.New()
|
|
if err != nil {
|
|
t.Fatalf("config.New: %v", err)
|
|
}
|
|
|
|
var requestLog bytes.Buffer
|
|
|
|
s := newServerWithConfig(t, cfg, &requestLog)
|
|
|
|
tests := []struct {
|
|
name string
|
|
peer string
|
|
want string
|
|
}{
|
|
{"through a reverse proxy", reverseProxyPeer, forwardedClient},
|
|
{"direct from a public address", untrustedPeer, untrustedPeerAddr},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
requestLog.Reset()
|
|
|
|
get := func(path string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
|
|
req.RemoteAddr = tt.peer
|
|
req.Header.Set("X-Forwarded-For", forwardedClient)
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.router.ServeHTTP(rec, req)
|
|
|
|
return rec
|
|
}
|
|
|
|
var answer struct {
|
|
Data IPLookupResponse `json:"data"`
|
|
}
|
|
|
|
rec := get("/ip/")
|
|
|
|
err := json.Unmarshal(rec.Body.Bytes(), &answer)
|
|
if err != nil {
|
|
t.Fatalf("%s: decoding the answer %q: %v", tt.name, rec.Body, err)
|
|
}
|
|
|
|
if answer.Data.Query != tt.want {
|
|
t.Errorf("%s: /ip/ looked up %q, want %q", tt.name, answer.Data.Query, tt.want)
|
|
}
|
|
|
|
page := get("/").Body.String()
|
|
if !strings.Contains(page, `data-client-ip="`+tt.want+`"`) {
|
|
t.Errorf("%s: the home page's card does not hold %s", tt.name, tt.want)
|
|
}
|
|
|
|
logged := loggedAddresses(requestLog.String())
|
|
if len(logged) == 0 {
|
|
t.Errorf("%s: the request log names no address", tt.name)
|
|
}
|
|
|
|
for _, addr := range logged {
|
|
if addr != tt.want {
|
|
t.Errorf("%s: the request log gives %s, want %s", tt.name, addr, tt.want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// newServerWithConfig returns a server built with cfg, on a new database, that
|
|
// reads the routes of newTestTable and logs its requests as JSON to
|
|
// requestLog.
|
|
func newServerWithConfig(
|
|
t *testing.T, cfg *config.Config, requestLog io.Writer,
|
|
) *Server {
|
|
t.Helper()
|
|
|
|
cfg.StateDir = t.TempDir()
|
|
|
|
db, err := database.New(cfg, logger.New())
|
|
if err != nil {
|
|
t.Fatalf("database.New: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
s := New(db, newTestTable(), streamer.New(logger.New(), metrics.New()),
|
|
&logger.Logger{Logger: slog.New(slog.NewJSONHandler(requestLog, nil))}, cfg)
|
|
s.resolver = &fakeResolver{}
|
|
|
|
return s
|
|
}
|
|
|
|
// loggedAddresses returns the address each line of the JSON request log
|
|
// names, without the port a direct request is logged with.
|
|
func loggedAddresses(requestLog string) []string {
|
|
var addrs []string
|
|
|
|
for line := range strings.Lines(requestLog) {
|
|
//nolint:tagliatelle // the field name is the key the request log uses
|
|
var entry struct {
|
|
RemoteAddr string `json:"remote_addr"`
|
|
}
|
|
|
|
err := json.Unmarshal([]byte(line), &entry)
|
|
if err != nil || entry.RemoteAddr == "" {
|
|
continue
|
|
}
|
|
|
|
addr, _, err := net.SplitHostPort(entry.RemoteAddr)
|
|
if err != nil {
|
|
addr = entry.RemoteAddr
|
|
}
|
|
|
|
addrs = append(addrs, addr)
|
|
}
|
|
|
|
return addrs
|
|
}
|
|
|
|
// 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(trustedProxyRange)}
|
|
|
|
tests := []struct {
|
|
name string
|
|
peer string
|
|
forwardedFor string
|
|
want string
|
|
}{
|
|
{"untrusted peer", untrustedPeer, forwardedClient, untrustedPeer},
|
|
{"trusted peer", trustedPeer, forwardedClient, forwardedClient},
|
|
{
|
|
"entries the client wrote", trustedPeer,
|
|
"198.51.100.7, " + forwardedClient, forwardedClient,
|
|
},
|
|
{
|
|
"two trusted proxies", trustedPeer,
|
|
forwardedClient + ", 10.0.0.3", forwardedClient,
|
|
},
|
|
{"no header", trustedPeer, "", trustedPeer},
|
|
{"entry not an address", trustedPeer, "client", trustedPeer},
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|