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:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user