Enabling /metrics lets an unauthenticated client grow the process without bound, and publishes live entrypoint UUIDs #254

Closed
opened 2026-08-24 00:56:31 +02:00 by clawbot · 2 comments
Collaborator

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 #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
Author
Collaborator

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.

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.
Author
Collaborator

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.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#254