The metrics middleware labels the handler dimension with the literal request path instead of the chi route pattern, so every distinct /webhook/<anything> mints a permanent new label set.
Measured during the deployability audit against a live instance with METRICS_USERNAME/METRICS_PASSWORD set:
Baseline scrape: 182 series, ~12 KB.
3,000 unauthenticated POSTs to distinct nonexistent entrypoint UUIDs.
Re-scraped again minutes later: still exactly 78,182 series. Nothing is ever evicted.
Roughly 26 permanent series and 3.5 KB of scrape output per distinct path, retained for the life of the process.
Rate limiting does not bound this. 45,025 of the new series carry code="429" — the metrics recorder is global middleware and runs BEFORE the route-level rate limiter, so rejected requests still mint series. The aggregate limit (1,200/min/IP) therefore caps this at roughly 31,000 new series per minute, indefinitely, from a single address.
Control: the same 6,000-distinct-path load against a metrics-DISABLED instance produced no retained growth (RSS 227 MB to 41 MB after GC).
Second effect, independent of the cardinality problem: live entrypoint UUIDs appear verbatim in the scrape as handler="/webhook/aaaaaaaa-bbbb-...". The entrypoint UUID is the receiver's only credential. Authenticated URL path segments leak the same way (handler="/source/SECRET-PATH-SEGMENT/logs").
Metrics are default-off, which is the only reason this is not already exploitable. But #209 shipped delivery metrics precisely so an operator can alert on delivery failures, so the expected production configuration turns this on — at which point a public unauthenticated endpoint becomes a remote memory-exhaustion vector.
This is the last surviving instance of the class fixed for Sentry in #179. The access log in the very same request path already does it correctly, emitting "url":"/webhook/{uuid}".
Definition of done
The handler label carries the chi route pattern, not the concrete path. chi.RouteContext(r.Context()).RoutePattern().
A request to an unmatched route must not mint a per-path series either — decide and state what label it carries.
No entrypoint UUID or other path-embedded secret appears anywhere in /metrics output.
A test asserting that N requests to distinct /webhook/<uuid> paths produce exactly ONE handler="/webhook/{uuid}" label set.
Confirm the fix holds for requests rejected by the rate limiter (code="429"), since those were the majority of the leaked series and they take a different path through the middleware stack.
Verification
make check green.
Re-run the probe in the PR body: scrape, drive several thousand distinct paths, scrape again, and show the series count flat.
The metrics middleware labels the `handler` dimension with the literal request path instead of the chi route pattern, so every distinct `/webhook/<anything>` mints a permanent new label set.
Measured during the deployability audit against a live instance with `METRICS_USERNAME`/`METRICS_PASSWORD` set:
- Baseline scrape: 182 series, ~12 KB.
- 3,000 unauthenticated POSTs to distinct nonexistent entrypoint UUIDs.
- Re-scrape: **78,182 series, 10,662,979 bytes**, 3,003 distinct `handler` labels, RSS 143 MB.
- Re-scraped again minutes later: still exactly 78,182 series. Nothing is ever evicted.
Roughly 26 permanent series and 3.5 KB of scrape output per distinct path, retained for the life of the process.
Rate limiting does not bound this. 45,025 of the new series carry `code="429"` — the metrics recorder is global middleware and runs BEFORE the route-level rate limiter, so rejected requests still mint series. The aggregate limit (1,200/min/IP) therefore caps this at roughly 31,000 new series per minute, indefinitely, from a single address.
Control: the same 6,000-distinct-path load against a metrics-DISABLED instance produced no retained growth (RSS 227 MB to 41 MB after GC).
Second effect, independent of the cardinality problem: live entrypoint UUIDs appear verbatim in the scrape as `handler="/webhook/aaaaaaaa-bbbb-..."`. The entrypoint UUID is the receiver's only credential. Authenticated URL path segments leak the same way (`handler="/source/SECRET-PATH-SEGMENT/logs"`).
Metrics are default-off, which is the only reason this is not already exploitable. But https://git.eeqj.de/sneak/webhooker/issues/209 shipped delivery metrics precisely so an operator can alert on delivery failures, so the expected production configuration turns this on — at which point a public unauthenticated endpoint becomes a remote memory-exhaustion vector.
This is the last surviving instance of the class fixed for Sentry in https://git.eeqj.de/sneak/webhooker/issues/179. The access log in the very same request path already does it correctly, emitting `"url":"/webhook/{uuid}"`.
## Definition of done
- The `handler` label carries the chi route pattern, not the concrete path. `chi.RouteContext(r.Context()).RoutePattern()`.
- A request to an unmatched route must not mint a per-path series either — decide and state what label it carries.
- No entrypoint UUID or other path-embedded secret appears anywhere in `/metrics` output.
- A test asserting that N requests to distinct `/webhook/<uuid>` paths produce exactly ONE `handler="/webhook/{uuid}"` label set.
- Confirm the fix holds for requests rejected by the rate limiter (`code="429"`), since those were the majority of the leaked series and they take a different path through the middleware stack.
## Verification
- `make check` green.
- Re-run the probe in the PR body: scrape, drive several thousand distinct paths, scrape again, and show the series count flat.
clawbot
added this to the 1.0.0 milestone 2026-08-24 00:56:35 +02:00
The handler label comes from std.Handler("", ...) in Middleware.Metrics: with an empty handler id, go-http-metrics substitutes reporter.URLPath(), i.e. the concrete path. The pattern is not available at that point — the metrics recorder is global middleware and chi only populates RouteContext.RoutePattern() during routeHTTP, after the whole global chain has been entered — so passing the pattern as the handler id is not possible.
What is available: Middleware.Measure captures reporter.Context() (the request context, which already holds the *chi.Context pointer that routing mutates in place) and passes it to every recorder call. The duration and size observations happen in a defer, after next() — exactly where the access log reads the pattern in accessLogURL, and the same place the Sentry scrub reads it.
So: wrap the Prometheus recorder in a decorator that rewrites props.ID from chi.RouteContext(ctx).RoutePattern() at record time. std.Handler and its response-writer interceptor stay untouched, so status and byte capture are unaffected. Because it records after the whole chain returns, requests rejected by the route-level ReceiverRateLimit (the code="429" majority) resolve the pattern too.
Unmatched routes: empty pattern collapses to the existing unmatchedRoute sentinel (unmatched), the same value the access log already uses.
http_requests_inflight is the one metric that cannot carry the pattern: it is incremented before routing and decremented after, so a pattern-derived label would unbalance the gauge. It gets a fixed handler="(all)" — one series, total concurrent requests. Called out in the PR body.
Tests: a chi router mirroring the real ordering (global metrics middleware, route-level rate limiter on /webhook/{uuid}), asserting N distinct UUIDs produce exactly one handler label value, for both the 200 and the 429 path, plus an unmatched-path flood. Plus the live scrape probe from the issue, before and after, reported in the PR.
Plan.
The `handler` label comes from `std.Handler("", ...)` in `Middleware.Metrics`: with an empty handler id, `go-http-metrics` substitutes `reporter.URLPath()`, i.e. the concrete path. The pattern is not available at that point — the metrics recorder is global middleware and chi only populates `RouteContext.RoutePattern()` during `routeHTTP`, after the whole global chain has been entered — so passing the pattern as the handler id is not possible.
What is available: `Middleware.Measure` captures `reporter.Context()` (the request context, which already holds the `*chi.Context` pointer that routing mutates in place) and passes it to every recorder call. The duration and size observations happen in a `defer`, after `next()` — exactly where the access log reads the pattern in `accessLogURL`, and the same place the Sentry scrub reads it.
So: wrap the Prometheus recorder in a decorator that rewrites `props.ID` from `chi.RouteContext(ctx).RoutePattern()` at record time. `std.Handler` and its response-writer interceptor stay untouched, so status and byte capture are unaffected. Because it records after the whole chain returns, requests rejected by the route-level `ReceiverRateLimit` (the `code="429"` majority) resolve the pattern too.
Unmatched routes: empty pattern collapses to the existing `unmatchedRoute` sentinel `(unmatched)`, the same value the access log already uses.
`http_requests_inflight` is the one metric that cannot carry the pattern: it is incremented before routing and decremented after, so a pattern-derived label would unbalance the gauge. It gets a fixed `handler="(all)"` — one series, total concurrent requests. Called out in the PR body.
Tests: a chi router mirroring the real ordering (global metrics middleware, route-level rate limiter on `/webhook/{uuid}`), asserting N distinct UUIDs produce exactly one `handler` label value, for both the 200 and the 429 path, plus an unmatched-path flood. Plus the live scrape probe from the issue, before and after, reported in the PR.
Built in #258 (branch issue-254-metrics-route-pattern, base next).
A metrics.Recorder decorator rewrites props.ID from chi.RouteContext(ctx).RoutePattern() at record time, which is after the wrapped handler returns and therefore after routing — the pattern cannot be supplied as the library's handler id, since the recorder is global middleware and Measure fixes the id up front. Unmatched routes carry the existing (unmatched) sentinel. http_requests_inflight cannot carry a pattern (incremented before routing, decremented after) and gets a fixed aggregate label; details in the PR body.
Verified. Reproduced the leak on the parent commit first: 107 series / 12,899 bytes to 78,132 series / 10,655,997 bytes / 3,002 handler labels after 3,000 unauthenticated POSTs to distinct invented UUIDs, unchanged on re-scrape. Same probe on the fixed build: 106 to 181 series, 20,907 bytes, 4 handler labels; a second 3,000-path flood left it at 181 — flat. Zero UUID-shaped strings anywhere in the scrape. 4,800 of the driven requests were rejected 429 and all of them landed on handler="/webhook/{uuid}", 25 series total. A separate unmatched-route flood (250 paths, two shapes) settled at 206 series and did not move.
make check green with GOFLAGS=-count=1, lint in Docker, 0 issues. Six new tests in internal/middleware/metrics_test.go; five were confirmed to fail against the unfixed recorder before being kept.
Built in https://git.eeqj.de/sneak/webhooker/pulls/258 (branch `issue-254-metrics-route-pattern`, base `next`).
A `metrics.Recorder` decorator rewrites `props.ID` from `chi.RouteContext(ctx).RoutePattern()` at record time, which is after the wrapped handler returns and therefore after routing — the pattern cannot be supplied as the library's handler id, since the recorder is global middleware and `Measure` fixes the id up front. Unmatched routes carry the existing `(unmatched)` sentinel. `http_requests_inflight` cannot carry a pattern (incremented before routing, decremented after) and gets a fixed aggregate label; details in the PR body.
Verified. Reproduced the leak on the parent commit first: 107 series / 12,899 bytes to 78,132 series / 10,655,997 bytes / 3,002 `handler` labels after 3,000 unauthenticated POSTs to distinct invented UUIDs, unchanged on re-scrape. Same probe on the fixed build: 106 to 181 series, 20,907 bytes, 4 handler labels; a second 3,000-path flood left it at 181 — flat. Zero UUID-shaped strings anywhere in the scrape. 4,800 of the driven requests were rejected 429 and all of them landed on `handler="/webhook/{uuid}"`, 25 series total. A separate unmatched-route flood (250 paths, two shapes) settled at 206 series and did not move.
`make check` green with `GOFLAGS=-count=1`, lint in Docker, `0 issues.` Six new tests in `internal/middleware/metrics_test.go`; five were confirmed to fail against the unfixed recorder before being kept.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The metrics middleware labels the
handlerdimension with the literal request path instead of the chi route pattern, so every distinct/webhook/<anything>mints a permanent new label set.Measured during the deployability audit against a live instance with
METRICS_USERNAME/METRICS_PASSWORDset:handlerlabels, RSS 143 MB.Roughly 26 permanent series and 3.5 KB of scrape output per distinct path, retained for the life of the process.
Rate limiting does not bound this. 45,025 of the new series carry
code="429"— the metrics recorder is global middleware and runs BEFORE the route-level rate limiter, so rejected requests still mint series. The aggregate limit (1,200/min/IP) therefore caps this at roughly 31,000 new series per minute, indefinitely, from a single address.Control: the same 6,000-distinct-path load against a metrics-DISABLED instance produced no retained growth (RSS 227 MB to 41 MB after GC).
Second effect, independent of the cardinality problem: live entrypoint UUIDs appear verbatim in the scrape as
handler="/webhook/aaaaaaaa-bbbb-...". The entrypoint UUID is the receiver's only credential. Authenticated URL path segments leak the same way (handler="/source/SECRET-PATH-SEGMENT/logs").Metrics are default-off, which is the only reason this is not already exploitable. But #209 shipped delivery metrics precisely so an operator can alert on delivery failures, so the expected production configuration turns this on — at which point a public unauthenticated endpoint becomes a remote memory-exhaustion vector.
This is the last surviving instance of the class fixed for Sentry in #179. The access log in the very same request path already does it correctly, emitting
"url":"/webhook/{uuid}".Definition of done
handlerlabel carries the chi route pattern, not the concrete path.chi.RouteContext(r.Context()).RoutePattern()./metricsoutput./webhook/<uuid>paths produce exactly ONEhandler="/webhook/{uuid}"label set.code="429"), since those were the majority of the leaked series and they take a different path through the middleware stack.Verification
make checkgreen.Plan.
The
handlerlabel comes fromstd.Handler("", ...)inMiddleware.Metrics: with an empty handler id,go-http-metricssubstitutesreporter.URLPath(), i.e. the concrete path. The pattern is not available at that point — the metrics recorder is global middleware and chi only populatesRouteContext.RoutePattern()duringrouteHTTP, after the whole global chain has been entered — so passing the pattern as the handler id is not possible.What is available:
Middleware.Measurecapturesreporter.Context()(the request context, which already holds the*chi.Contextpointer that routing mutates in place) and passes it to every recorder call. The duration and size observations happen in adefer, afternext()— exactly where the access log reads the pattern inaccessLogURL, and the same place the Sentry scrub reads it.So: wrap the Prometheus recorder in a decorator that rewrites
props.IDfromchi.RouteContext(ctx).RoutePattern()at record time.std.Handlerand its response-writer interceptor stay untouched, so status and byte capture are unaffected. Because it records after the whole chain returns, requests rejected by the route-levelReceiverRateLimit(thecode="429"majority) resolve the pattern too.Unmatched routes: empty pattern collapses to the existing
unmatchedRoutesentinel(unmatched), the same value the access log already uses.http_requests_inflightis the one metric that cannot carry the pattern: it is incremented before routing and decremented after, so a pattern-derived label would unbalance the gauge. It gets a fixedhandler="(all)"— one series, total concurrent requests. Called out in the PR body.Tests: a chi router mirroring the real ordering (global metrics middleware, route-level rate limiter on
/webhook/{uuid}), asserting N distinct UUIDs produce exactly onehandlerlabel value, for both the 200 and the 429 path, plus an unmatched-path flood. Plus the live scrape probe from the issue, before and after, reported in the PR.Built in #258 (branch
issue-254-metrics-route-pattern, basenext).A
metrics.Recorderdecorator rewritesprops.IDfromchi.RouteContext(ctx).RoutePattern()at record time, which is after the wrapped handler returns and therefore after routing — the pattern cannot be supplied as the library's handler id, since the recorder is global middleware andMeasurefixes the id up front. Unmatched routes carry the existing(unmatched)sentinel.http_requests_inflightcannot carry a pattern (incremented before routing, decremented after) and gets a fixed aggregate label; details in the PR body.Verified. Reproduced the leak on the parent commit first: 107 series / 12,899 bytes to 78,132 series / 10,655,997 bytes / 3,002
handlerlabels after 3,000 unauthenticated POSTs to distinct invented UUIDs, unchanged on re-scrape. Same probe on the fixed build: 106 to 181 series, 20,907 bytes, 4 handler labels; a second 3,000-path flood left it at 181 — flat. Zero UUID-shaped strings anywhere in the scrape. 4,800 of the driven requests were rejected 429 and all of them landed onhandler="/webhook/{uuid}", 25 series total. A separate unmatched-route flood (250 paths, two shapes) settled at 206 series and did not move.make checkgreen withGOFLAGS=-count=1, lint in Docker,0 issues.Six new tests ininternal/middleware/metrics_test.go; five were confirmed to fail against the unfixed recorder before being kept.