Add the HTTP hardening the policy requires before 1.0 (closes #108)
check / check (push) Waiting to run
check / check (push) Waiting to run
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:
@@ -0,0 +1,205 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The addresses the forwarding tests use: the client a proxy names in
|
||||
// X-Forwarded-For, a peer inside the trusted range of
|
||||
// TestRealIPMiddlewareTrustsOnlyConfiguredProxies, and a peer outside it,
|
||||
// which lies in testPrefixV4 so that an IP lookup of it finds a route.
|
||||
const (
|
||||
forwardedClient = "203.0.113.9"
|
||||
trustedPeer = "10.0.0.2:1234"
|
||||
untrustedPeerAddr = "192.0.2.1"
|
||||
untrustedPeer = untrustedPeerAddr + ":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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user