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