Keep the entrypoint UUID out of webhooker's own logs (closes #531)
check / check (push) Waiting to run

On the receiver route the access log and the per-entrypoint 429 line
name the route pattern whatever the status. The received line names
the entrypoint and webhook IDs, and the unknown-entrypoint line logs
no path. The pattern is middleware.ReceiverRoute, which routes.go
registers the receiver with.

The README says the proxy's access log holds the UUID and must be
protected like the URL, and its access-log, rate-limit, Sentry and
log-size passages now match the code.

Model: opus-5-5
This commit is contained in:
2026-10-15 08:31:22 +00:00
parent 573f7f986a
commit 55a7ed41a9
10 changed files with 267 additions and 162 deletions
+39 -32
View File
@@ -13,7 +13,6 @@ import (
"time"
basicauth "github.com/99designs/basicauth-go"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
"github.com/prometheus/client_golang/prometheus"
@@ -96,14 +95,14 @@ const (
// It is also the ceiling on every OTHER line this service writes
// THROUGH SLOG that carries text an UNAUTHENTICATED client
// supplies. Those lines — the MaxBodySize rejection, the CSRF
// rejection, the rate-limit rejection, the unauthenticated-request
// and unknown-entrypoint DEBUG lines, the failed-login DEBUG
// lines, and the two login-throttle WARN lines ("login failure
// limit exceeded" in loginguard.go and "password verification
// capacity exhausted" in internal/handlers/auth.go) — spend the
// same per-field budgets, and each carries strictly fewer
// client-supplied fields than the access log does,
// so none of them can reach a width the access log cannot. That is
// rejection, the unauthenticated-request DEBUG line, the
// failed-login DEBUG lines, and the two login-throttle WARN
// lines ("login failure limit exceeded" in loginguard.go and
// "password verification capacity exhausted" in
// internal/handlers/auth.go) — spend the same per-field budgets,
// and each carries strictly fewer client-supplied fields than the
// access log does, so none of them can reach a width the access
// log cannot. That is
// asserted directly, per line and under both handlers, rather than
// left to the reasoning: see logbound_test.go in this package and
// in internal/handlers.
@@ -151,6 +150,12 @@ const (
MaxAccessLogLineBytes = 2560
)
// ReceiverRoute is the route pattern the webhook receiver is served
// at. Its path carries the entrypoint UUID, which is the receiver's
// only credential, so the log lines this package writes name a
// request on this route by the pattern, never by its path.
const ReceiverRoute = "/h/{uuid}"
//nolint:revive // MiddlewareParams is a standard fx naming convention.
type MiddlewareParams struct {
fx.In
@@ -268,14 +273,14 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
// marker.
//
// The path on those branches is bounded by the service's routes or by
// the operator's data — a 2xx on the receiver means the UUID named a
// stored entrypoint, a 2xx under /s means the file is in the embedded
// tree. The query is not bounded by anything: /.well-known/healthcheck
// and /s/* take no authentication and sit behind no rate limiter, and
// /pages/login behind only the login limiter, so any of them will
// answer 200 to a URL carrying an arbitrary number of arbitrary bytes
// after the '?'. Keeping the path and dropping the query is what makes
// this branch as bounded as the pattern branches below.
// the operator's data — a 2xx under /s means the file is in the
// embedded tree. The query is not bounded by anything:
// /.well-known/healthcheck and /s/* take no authentication and sit
// behind no rate limiter, and /pages/login behind only the login
// limiter, so any of them will answer 200 to a URL carrying an
// arbitrary number of arbitrary bytes after the '?'. Keeping the path
// and dropping the query is what makes this branch as bounded as the
// pattern branches below.
//
// Nothing debuggable is lost. The only query parameters the service
// reads are the login page's `next`, the page to return to,
@@ -297,16 +302,19 @@ func concreteLogURL(r *http.Request) string {
// accessLogURL returns the value for the access log's url field.
//
// 2xx and 5xx responses get the concrete path (see concreteLogURL). 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.
// On ReceiverRoute it is the route pattern whatever the status, so
// the entrypoint UUID in the path is never logged.
//
// On every other route, 2xx and 5xx responses get the concrete path
// (see concreteLogURL). A success resolved against a static route or
// against the operator's own data, 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 /h/ path, 303 to the login page on any
// invented /user/ path. Logging the concrete URL there lets a flood
// the outcomes an unauthenticated client drives for free: 303 to the
// login page on any invented /user/ path, 404 on any path that
// matches no route. 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
@@ -315,18 +323,17 @@ func concreteLogURL(r *http.Request) string {
// 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 {
pattern := routePatternID(r.Context())
if pattern == ReceiverRoute {
return pattern
}
if status < http.StatusMultipleChoices ||
status >= http.StatusInternalServerError {
return concreteLogURL(r)
}
if rc := chi.RouteContext(r.Context()); rc != nil {
if pattern := rc.RoutePattern(); pattern != "" {
return pattern
}
}
return unmatchedRoute
return pattern
}
// Logging returns middleware that logs each HTTP request with