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
+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)
})
}
}