Log the route pattern for redirected and rejected requests (closes #146)
All checks were successful
check / check (push) Successful in 2m57s

The access log wrote one INFO line per request carrying
r.URL.String(). Registered with Use, it runs ahead of the route
limiter, so a client flooding the unauthenticated receiver with
invented paths wrote attacker-chosen text of attacker-chosen length
into the operator's log, one line per request.

3xx and 4xx responses now log the chi route pattern in place of the
concrete URL, and the fixed literal "(unmatched)" when routing matched
nothing at all. One line per request is retained, so real traffic
stays observable and rate accounting still works, but the line's
content is now bounded by the service's own route table. 2xx and 5xx
keep the full URL.

The pattern is only populated after routing, so it is read in the
deferred part of the handler rather than before next.ServeHTTP.

No other access-log field changes.
This commit is contained in:
2026-08-17 20:41:53 +00:00
parent 2ee720a9af
commit fc115058ef
3 changed files with 353 additions and 3 deletions

View File

@@ -9,6 +9,7 @@ import (
"time"
basicauth "github.com/99designs/basicauth-go"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
@@ -25,6 +26,12 @@ const (
// corsMaxAge is the maximum time (in seconds) that a
// preflight response can be cached.
corsMaxAge = 300
// unmatchedRoute is logged in the access log's url field when a
// redirected or rejected request matched no route pattern at
// all. Every byte of such a path is client-chosen, so none of it
// is logged.
unmatchedRoute = "(unmatched)"
)
//nolint:revive // MiddlewareParams is a standard fx naming convention.
@@ -94,6 +101,40 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.ResponseWriter.WriteHeader(code)
}
// accessLogURL returns the value for the access log's url field.
//
// 2xx and 5xx responses get the concrete URL. A success resolved
// against a static route or against the operator's own data — on the
// receiver, a 2xx means the UUID named a stored entrypoint — and a
// server error is our own bug, where the exact URL is the primary
// evidence and which no client can provoke at will.
//
// 3xx and 4xx responses get the chi route pattern instead. Those are
// the outcomes an unauthenticated client drives for free: 404 or 429
// on any invented /webhook/ path, 303 to the login page on any
// invented /user/ path. Logging the concrete URL there lets a flood
// write attacker-chosen text, of attacker-chosen length, into the
// operator's log at one line per request. The pattern comes from the
// router's own table, so it is bounded by the service's routes while
// still naming which class of request was rejected.
//
// The pattern is only populated once routing has run, so this must be
// called after the handler returns, not before.
func accessLogURL(r *http.Request, status int) string {
if status < http.StatusMultipleChoices ||
status >= http.StatusInternalServerError {
return r.URL.String()
}
if rc := chi.RouteContext(r.Context()); rc != nil {
if pattern := rc.RoutePattern(); pattern != "" {
return pattern
}
}
return unmatchedRoute
}
// Logging returns middleware that logs each HTTP request with
// timing and metadata.
func (s *Middleware) Logging() func(http.Handler) http.Handler {
@@ -121,7 +162,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
s.log.Info("http request",
"request_start", start,
"method", r.Method,
"url", r.URL.String(),
"url", accessLogURL(r, lrw.statusCode),
"useragent", r.UserAgent(),
"request_id", requestID,
"referer", r.Referer(),