Add the HTTP hardening the policy requires before 1.0 (closes #108)
check / check (push) Canceled after 0s
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 was merged in pull request #132.
This commit is contained in:
+64
-26
@@ -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,9 @@ 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 +116,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 +129,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 +265,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 +555,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 +636,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 +681,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 +709,8 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
response.Errors = append(response.Errors, ip+": "+err.Error())
|
||||
response.Errors = append(response.Errors,
|
||||
ip+": "+s.lookupErrorMessage(ip, err))
|
||||
|
||||
continue
|
||||
}
|
||||
@@ -736,6 +751,19 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// lookupErrorMessage returns what an IP lookup answer says about err, the
|
||||
// failed lookup of ip: err's own text when ip is not a valid address or has no
|
||||
// route, otherwise a fixed message, with err in the log.
|
||||
func (s *Server) lookupErrorMessage(ip string, err error) string {
|
||||
if errors.Is(err, database.ErrNoRoute) || errors.Is(err, database.ErrInvalidIP) {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
s.logger.Error("Failed to look up IP", "ip", ip, "error", err)
|
||||
|
||||
return internalErrorMessage
|
||||
}
|
||||
|
||||
// lookupTarget returns what an IP lookup request asks for: the ip or addr URL
|
||||
// parameter, or else the client's own address.
|
||||
func lookupTarget(r *http.Request) string {
|
||||
@@ -766,9 +794,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 +822,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 +851,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 +886,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 +967,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 +985,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 +1039,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 +1076,7 @@ func (s *Server) handleASDetail() http.HandlerFunc {
|
||||
IPv6Count int
|
||||
Peers []database.ASPeer
|
||||
PeerCount int
|
||||
CSPNonce string
|
||||
}{
|
||||
ASN: asInfo,
|
||||
IPv4Prefixes: ipv4Prefixes,
|
||||
@@ -1048,6 +1086,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
|
||||
@@ -1058,9 +1097,7 @@ func (s *Server) handleASDetail() http.HandlerFunc {
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
tmpl := templates.ASDetailTemplate()
|
||||
|
||||
err = tmpl.Execute(w, data)
|
||||
err = templates.ASDetailTemplate().Execute(w, data)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to render AS detail template", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
@@ -1085,7 +1122,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 +1141,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 +1168,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
|
||||
Origins []*ASNInfo
|
||||
PeerCount int
|
||||
OriginCount int
|
||||
CSPNonce string
|
||||
}{
|
||||
Prefix: prefix,
|
||||
MaskLength: maskLength,
|
||||
@@ -1139,6 +1177,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
|
||||
@@ -1151,9 +1190,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
tmpl := templates.PrefixDetailTemplate()
|
||||
|
||||
err = tmpl.Execute(w, data)
|
||||
err = templates.PrefixDetailTemplate().Execute(w, data)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to render prefix detail template", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
@@ -1303,7 +1340,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 +1361,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
|
||||
|
||||
Reference in New Issue
Block a user