Bound the /metrics method label (closes #261) #264

Merged
clawbot merged 1 commits from issue-261-metrics-method-label into next 2026-08-24 02:03:19 +02:00
Collaborator

Closes #261.

method was recorded as r.Method verbatim. net/http accepts any RFC 9110 token as a method and hands it through, so the label was bounded at nothing.

The fix goes in the recorder seam #258 added for handler — renamed routePatternRecorder -> boundedLabelRecorder, since it now bounds both request-controlled dimensions. A method the router can route is kept verbatim; anything else carries the existing (unmatched) sentinel, aliased (unmatchedMethod = unmatchedRoute) rather than respelled, so one idea does not read as two unmatched states in a scrape. Bound: 10 values.

The retained set is the nine of chi's methodMap, restated against the net/http constants because chi does not export it. A token outside it can only ever produce chi's 405, so folding those together loses nothing a scrape could have used. chi.RegisterMethod would extend the router's set; this service never calls it (verified: grep -rn RegisterMethod over internal/ and cmd/ returns nothing), and the code comment says a caller that started to must extend the switch with it.

Reproduced first, then fixed

Both binaries built with make build, run with METRICS_USERNAME/METRICS_PASSWORD set, driven with 300 random 12-character method tokens against /, counting non-comment lines from an authenticated /metrics scrape:

Binary Lines before Lines after 300 tokens
next at fd5966f (unmodified) 81 7,606
this branch 331 331

The next delta of 7,525 lines matches the issue's measurement exactly (it reported 106 -> 7,631 against a warmer baseline). The branch's higher starting count is only because that instance had already been driven with real traffic for the audit below.

The same reproduction at test scale, TestMetrics_MethodSeriesCountIsFlatUnderAFlood run against the unmodified recorder before the two-line wiring went in:

  • before the fix: 300 tokens -> 601 series / 7,501 lines; 1,200 more -> 3,001 series / 37,501 lines (test failed)
  • after the fix: 300 tokens -> 3 series / 26 lines; 1,200 more -> 3 series / 26 lines (test passes, and logs both figures)

Live label values from the fixed binary, showing the invented token ZQXJ7BK2M4V9 collapsed while the real methods stayed apart:

http_request_duration_seconds_count{code="200",handler="/.well-known/healthcheck",method="GET",service=""} 1
http_request_duration_seconds_count{code="303",handler="/",method="GET",service=""} 1
http_request_duration_seconds_count{code="404",handler="(unmatched)",method="GET",service=""} 1
http_request_duration_seconds_count{code="405",handler="(unmatched)",method="(unmatched)",service=""} 1
http_request_duration_seconds_count{code="405",handler="(unmatched)",method="DELETE",service=""} 1
http_request_duration_seconds_count{code="405",handler="(unmatched)",method="HEAD",service=""} 1
http_request_duration_seconds_count{code="405",handler="(unmatched)",method="OPTIONS",service=""} 1
http_request_duration_seconds_count{code="405",handler="(unmatched)",method="POST",service=""} 1
http_request_duration_seconds_count{code="405",handler="(unmatched)",method="PUT",service=""} 1
http_requests_inflight{handler="(all)",service=""} 1

Label audit

Enumerated from a live authenticated scrape of a running instance rather than from reading alone — curl -u ... /metrics, every family and its label names extracted. That is the complete list of what /metrics exposes; nothing below was assumed present or absent.

Metric Label Populated by Attacker-controlled? Bound
http_request_duration_seconds, http_response_size_bytes handler chi route pattern via routePatternID, sentinel when unmatched no, since #258 registered patterns + 1
same two method methodID(r.Method) was yes — this PR 9 + 1 = 10
same two code strconv.Itoa(reporter.StatusCode()) in go-http-metrics, where StatusCode() reads the interceptor field written only by WriteHeader no status codes this service's own handlers write; net/http rejects anything outside 100-999
same two, and http_requests_inflight service ghmm.Config.Service, left unset in metricsMiddleware no 1 (empty string)
http_requests_inflight handler fixed inflightHandler constant no 1 ((all))
http_request_duration_seconds, http_response_size_bytes le histogram bucket bounds fixed at construction no fixed per metric
all 8 labelled webhooker_* delivery metrics target_type normalizeTargetType, which folds anything outside knownTargetTypes to unknown no — set by an authenticated operator, and folded regardless 4 + 1 = 5
webhooker_events_received_total, webhooker_events_resubmitted_total no labels n/a 1 series each
promhttp_metric_handler_requests_total code promhttp's own instrumentation no 3 (200, 500, 503), observed in the scrape
go_gc_duration_seconds quantile Go collector no 5
go_info version Go build version no 1
remaining go_*, process_*, promhttp_metric_handler_requests_in_flight no labels n/a 1 series each

How each no was verified:

  • code: traced the value to middleware.Measure in go-http-metrics, which formats Reporter.StatusCode(); std.stdReporter.StatusCode() returns responseWriterInterceptor.statusCode, an int assigned only in WriteHeader. Nothing copies a request header or path into it. A request cannot name its own status.
  • service: metricsMiddleware constructs ghmm.New(ghmm.Config{Recorder: ...}) and sets no Service, so the field is "" for the process lifetime. Confirmed empty in the live scrape above.
  • target_type: every call site in internal/metrics/metrics.go goes through normalizeTargetType, and queueDepthLabels/initSeries enumerate the domain up front. A database.TargetType outside the four constants collapses to unknown. internal/delivery and internal/database were read for the call sites and not modified.
  • The go_*/process_*/promhttp_* families are registered by client_golang on the default registry, which is the one promhttp.Handler() gathers; their labels are compile-time constants of those collectors. Their presence and label sets are from the live scrape, not assumed.

webhooker_delivery_duration_seconds does not appear in a scrape until the first delivery, because initSeries does not materialise it (unlike the counters and gauges). Its labels are target_type + le, both bounded as above. Noted as an observation, not a defect.

No third unbounded dimension was found.

Tests

New, in internal/middleware/metrics_method_test.go:

  • TestMetrics_InventedMethodsMintOneLabelSet — 300 distinct invented tokens, exactly one method label, and no token echoed into any label. Asserts on the count rather than the set so a regression reports a number instead of dumping 300 tokens.
  • TestMetrics_MethodSeriesCountIsFlatUnderAFlood — scrape, 300 tokens, scrape, 1,200 more, scrape; both series count and rendered exposition-line count must be flat. Lines are counted through promhttp.HandlerFor, i.e. the same handler /metrics mounts, because one histogram label set is ~25 lines and the line count is what the issue measured.
  • TestMetrics_RealMethodsStayDistinct — the positive control. All nine routable methods (GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH, TRACE, CONNECT) keep their own duration series under the route pattern, one sample each.
  • TestMetrics_MethodSentinelIsTheRouteSentinel — pins that the two sentinels are one string, so a future edit cannot introduce a second spelling.

drivePaths in metrics_test.go was refactored onto a shared drive/probe helper, since the probes now vary the method as well as the path; its signature and behaviour are unchanged.

Verification

make check green with GOFLAGS=-count=1: 580 tests pass across 20 packages, golangci-lint reports 0 issues. in the Docker lint stage (summary line present, so the linter ran), fmt-check clean. All probe processes stopped and scratch removed; ports 18961/18962 released.

Out of scope and untouched: no new metrics, no changes under internal/delivery or internal/database, no TODO.md edit.

Closes https://git.eeqj.de/sneak/webhooker/issues/261. `method` was recorded as `r.Method` verbatim. `net/http` accepts any RFC 9110 token as a method and hands it through, so the label was bounded at nothing. The fix goes in the recorder seam https://git.eeqj.de/sneak/webhooker/pulls/258 added for `handler` — renamed `routePatternRecorder` -> `boundedLabelRecorder`, since it now bounds both request-controlled dimensions. A method the router can route is kept verbatim; anything else carries the existing `(unmatched)` sentinel, aliased (`unmatchedMethod = unmatchedRoute`) rather than respelled, so one idea does not read as two unmatched states in a scrape. Bound: 10 values. The retained set is the nine of chi's `methodMap`, restated against the `net/http` constants because chi does not export it. A token outside it can only ever produce chi's 405, so folding those together loses nothing a scrape could have used. `chi.RegisterMethod` would extend the router's set; this service never calls it (verified: `grep -rn RegisterMethod` over `internal/` and `cmd/` returns nothing), and the code comment says a caller that started to must extend the switch with it. ## Reproduced first, then fixed Both binaries built with `make build`, run with `METRICS_USERNAME`/`METRICS_PASSWORD` set, driven with 300 random 12-character method tokens against `/`, counting non-comment lines from an authenticated `/metrics` scrape: | Binary | Lines before | Lines after 300 tokens | | ------ | ------------ | ---------------------- | | `next` at `fd5966f` (unmodified) | 81 | **7,606** | | this branch | 331 | **331** | The `next` delta of 7,525 lines matches the issue's measurement exactly (it reported 106 -> 7,631 against a warmer baseline). The branch's higher starting count is only because that instance had already been driven with real traffic for the audit below. The same reproduction at test scale, `TestMetrics_MethodSeriesCountIsFlatUnderAFlood` run against the unmodified recorder before the two-line wiring went in: - before the fix: 300 tokens -> 601 series / 7,501 lines; 1,200 more -> 3,001 series / 37,501 lines (test **failed**) - after the fix: 300 tokens -> 3 series / 26 lines; 1,200 more -> 3 series / 26 lines (test **passes**, and logs both figures) Live label values from the fixed binary, showing the invented token `ZQXJ7BK2M4V9` collapsed while the real methods stayed apart: ``` http_request_duration_seconds_count{code="200",handler="/.well-known/healthcheck",method="GET",service=""} 1 http_request_duration_seconds_count{code="303",handler="/",method="GET",service=""} 1 http_request_duration_seconds_count{code="404",handler="(unmatched)",method="GET",service=""} 1 http_request_duration_seconds_count{code="405",handler="(unmatched)",method="(unmatched)",service=""} 1 http_request_duration_seconds_count{code="405",handler="(unmatched)",method="DELETE",service=""} 1 http_request_duration_seconds_count{code="405",handler="(unmatched)",method="HEAD",service=""} 1 http_request_duration_seconds_count{code="405",handler="(unmatched)",method="OPTIONS",service=""} 1 http_request_duration_seconds_count{code="405",handler="(unmatched)",method="POST",service=""} 1 http_request_duration_seconds_count{code="405",handler="(unmatched)",method="PUT",service=""} 1 http_requests_inflight{handler="(all)",service=""} 1 ``` ## Label audit Enumerated from a live authenticated scrape of a running instance rather than from reading alone — `curl -u ... /metrics`, every family and its label names extracted. That is the complete list of what `/metrics` exposes; nothing below was assumed present or absent. | Metric | Label | Populated by | Attacker-controlled? | Bound | | ------ | ----- | ------------ | -------------------- | ----- | | `http_request_duration_seconds`, `http_response_size_bytes` | `handler` | chi route pattern via `routePatternID`, sentinel when unmatched | no, since #258 | registered patterns + 1 | | same two | `method` | `methodID(r.Method)` | **was yes — this PR** | 9 + 1 = 10 | | same two | `code` | `strconv.Itoa(reporter.StatusCode())` in go-http-metrics, where `StatusCode()` reads the interceptor field written only by `WriteHeader` | no | status codes this service's own handlers write; `net/http` rejects anything outside 100-999 | | same two, and `http_requests_inflight` | `service` | `ghmm.Config.Service`, left unset in `metricsMiddleware` | no | 1 (empty string) | | `http_requests_inflight` | `handler` | fixed `inflightHandler` constant | no | 1 (`(all)`) | | `http_request_duration_seconds`, `http_response_size_bytes` | `le` | histogram bucket bounds fixed at construction | no | fixed per metric | | all 8 labelled `webhooker_*` delivery metrics | `target_type` | `normalizeTargetType`, which folds anything outside `knownTargetTypes` to `unknown` | no — set by an authenticated operator, and folded regardless | 4 + 1 = 5 | | `webhooker_events_received_total`, `webhooker_events_resubmitted_total` | — | no labels | n/a | 1 series each | | `promhttp_metric_handler_requests_total` | `code` | promhttp's own instrumentation | no | 3 (`200`, `500`, `503`), observed in the scrape | | `go_gc_duration_seconds` | `quantile` | Go collector | no | 5 | | `go_info` | `version` | Go build version | no | 1 | | remaining `go_*`, `process_*`, `promhttp_metric_handler_requests_in_flight` | — | no labels | n/a | 1 series each | How each `no` was verified: - `code`: traced the value to `middleware.Measure` in go-http-metrics, which formats `Reporter.StatusCode()`; `std.stdReporter.StatusCode()` returns `responseWriterInterceptor.statusCode`, an `int` assigned only in `WriteHeader`. Nothing copies a request header or path into it. A request cannot name its own status. - `service`: `metricsMiddleware` constructs `ghmm.New(ghmm.Config{Recorder: ...})` and sets no `Service`, so the field is `""` for the process lifetime. Confirmed empty in the live scrape above. - `target_type`: every call site in `internal/metrics/metrics.go` goes through `normalizeTargetType`, and `queueDepthLabels`/`initSeries` enumerate the domain up front. A `database.TargetType` outside the four constants collapses to `unknown`. `internal/delivery` and `internal/database` were read for the call sites and not modified. - The `go_*`/`process_*`/`promhttp_*` families are registered by `client_golang` on the default registry, which is the one `promhttp.Handler()` gathers; their labels are compile-time constants of those collectors. Their presence and label sets are from the live scrape, not assumed. `webhooker_delivery_duration_seconds` does not appear in a scrape until the first delivery, because `initSeries` does not materialise it (unlike the counters and gauges). Its labels are `target_type` + `le`, both bounded as above. Noted as an observation, not a defect. No third unbounded dimension was found. ## Tests New, in `internal/middleware/metrics_method_test.go`: - `TestMetrics_InventedMethodsMintOneLabelSet` — 300 distinct invented tokens, exactly one `method` label, and no token echoed into any label. Asserts on the count rather than the set so a regression reports a number instead of dumping 300 tokens. - `TestMetrics_MethodSeriesCountIsFlatUnderAFlood` — scrape, 300 tokens, scrape, 1,200 more, scrape; both series count and rendered exposition-line count must be flat. Lines are counted through `promhttp.HandlerFor`, i.e. the same handler `/metrics` mounts, because one histogram label set is ~25 lines and the line count is what the issue measured. - `TestMetrics_RealMethodsStayDistinct` — the positive control. All nine routable methods (`GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `OPTIONS`, `PATCH`, `TRACE`, `CONNECT`) keep their own duration series under the route pattern, one sample each. - `TestMetrics_MethodSentinelIsTheRouteSentinel` — pins that the two sentinels are one string, so a future edit cannot introduce a second spelling. `drivePaths` in `metrics_test.go` was refactored onto a shared `drive`/`probe` helper, since the probes now vary the method as well as the path; its signature and behaviour are unchanged. ## Verification `make check` green with `GOFLAGS=-count=1`: 580 tests pass across 20 packages, `golangci-lint` reports `0 issues.` in the Docker lint stage (summary line present, so the linter ran), `fmt-check` clean. All probe processes stopped and scratch removed; ports 18961/18962 released. Out of scope and untouched: no new metrics, no changes under `internal/delivery` or `internal/database`, no `TODO.md` edit.
clawbot added 1 commit 2026-08-24 01:47:50 +02:00
Bound the /metrics method label (closes #261)
All checks were successful
check / check (push) Successful in 3m6s
2ef52bbac2
The `method` label was recorded as `r.Method` verbatim. net/http
accepts any RFC 9110 token as a method and passes it through, so the
label was bounded at nothing: 300 requests to `/` carrying random
12-character method tokens took a live scrape from 81 lines to 7,606,
unauthenticated and on a route with no rate limiter. This is the same
remote memory-exhaustion vector the handler label carried, reached
through a second dimension.

The fix goes in the same recorder seam that bounds the handler label,
which is renamed to reflect that it now bounds both. A method the
router can route is kept verbatim, so real methods stay
distinguishable; anything else carries the existing `(unmatched)`
sentinel, deliberately the same spelling rather than a second one for
the same idea. The bound is ten values.

The retained set is restated against the net/http constants because
chi's own methodMap is unexported. A token outside it can only ever
produce chi's 405, so folding those together loses nothing a scrape
could have used.

README's Metrics section gains the inbound HTTP metrics, the bound on
each of their labels, and the aggregate
`http_requests_inflight{handler="(all)"}` semantics.
clawbot added the needs-review label 2026-08-24 01:47:58 +02:00
clawbot self-assigned this 2026-08-24 01:47:59 +02:00
Author
Collaborator

PASS — independently reproduced and verified at 2ef52bb: on parent fd5966f my own probe took a live scrape from 106 to 7,631 lines / 301 method values under 300 random 12-char tokens (601 after 600 tokens); this branch went 106 to 156 and stayed flat at 156 across a doubled flood. Positive control live: all nine chi methods keep distinct series with correct code; 200 / 404 / 405 / 429 each landed with the right code and method. Independent label enumeration from a live scrape found the complete label-name set to be code, handler, le, method, quantile, service, target_type, version — each traced to a bounded writer (promhttp_metric_handler_requests_total observed at exactly 200/500/503); no third unbounded dimension. make check green from a clean clone with GOFLAGS=-count=1 in 1m25s: 580 tests / 20 packages / zero (cached) markers / 0 failures, lint 0 issues. in the Docker stage under --no-cache-filter=lint (55.5s, not a cache hit), fmt-check clean. CI green, mergeable, no attribution trailers, (closes #261) present.

Anomalies and disclosures, none blocking:

  • Case folding is correct and deliberate, worth recording. get, Post and gEt all fold onto the sentinel. That is faithful to the router, not an accident: chi v1.5.5 upper-cases only at registration (Mux.Method), while routeHTTP does a bare methodMap[rctx.RouteMethod] lookup, so a lowercase get can only ever 405. Confirmed live.
  • chi's methodMap matches the switch exactly — same nine net/http constants in chi v1.5.5, and RegisterMethod is called nowhere in the repo or in any vendored go-chi package. Residual risk of the duplicated source of truth: TestMetrics_RealMethodsStayDistinct would catch chi dropping a method but not chi adding one, which would silently fold onto (unmatched). No clean guard exists while the map stays unexported, so I am waiving this rather than requiring it.
  • Sentinel aliasing is fine. handler and method are separate label names and never merge series, and by construction method="(unmatched)" implies handler="(unmatched)" (chi 405s before it matches a pattern). So (unmatched)/(unmatched) reads as a bad method and (unmatched)/GET as a bad path — distinguishable, verified live.
  • Cosmetic, failure-path only: internal/middleware/metrics_method_test.go:189 assert.Contains(t, keys(labels), ...) dumps all 300 tokens when it fails, which is the thing the comment three lines above says the count-based assertion was chosen to avoid.
  • Disclosure: to confirm fail-first I temporarily reverted the two props.Method = methodID(...) lines in my own throwaway clone, ran make test (both cardinality tests failed at 601 to 3,001 series / 7,501 to 37,501 lines, matching the PR body; the positive control correctly passed both before and after), then restored. Nothing was changed, committed or pushed here.
  • On the deliveryDuration observation: confirmed — webhooker_delivery_duration_seconds is genuinely absent from a fresh scrape. Real, out of scope for this PR, and worth a follow-up issue against #209. Not required here.
**PASS** — independently reproduced and verified at `2ef52bb`: on parent `fd5966f` my own probe took a live scrape from 106 to 7,631 lines / 301 `method` values under 300 random 12-char tokens (601 after 600 tokens); this branch went 106 to 156 and stayed flat at 156 across a doubled flood. Positive control live: all nine chi methods keep distinct series with correct `code`; 200 / 404 / 405 / 429 each landed with the right `code` and `method`. Independent label enumeration from a live scrape found the complete label-name set to be `code`, `handler`, `le`, `method`, `quantile`, `service`, `target_type`, `version` — each traced to a bounded writer (`promhttp_metric_handler_requests_total` observed at exactly `200`/`500`/`503`); no third unbounded dimension. `make check` green from a clean clone with `GOFLAGS=-count=1` in 1m25s: 580 tests / 20 packages / zero `(cached)` markers / 0 failures, lint `0 issues.` in the Docker stage under `--no-cache-filter=lint` (55.5s, not a cache hit), `fmt-check` clean. CI green, mergeable, no attribution trailers, `(closes #261)` present. Anomalies and disclosures, none blocking: - **Case folding is correct and deliberate, worth recording.** `get`, `Post` and `gEt` all fold onto the sentinel. That is faithful to the router, not an accident: chi v1.5.5 upper-cases only at *registration* (`Mux.Method`), while `routeHTTP` does a bare `methodMap[rctx.RouteMethod]` lookup, so a lowercase `get` can only ever 405. Confirmed live. - **chi's `methodMap` matches the switch exactly** — same nine `net/http` constants in chi v1.5.5, and `RegisterMethod` is called nowhere in the repo or in any vendored `go-chi` package. Residual risk of the duplicated source of truth: `TestMetrics_RealMethodsStayDistinct` would catch chi *dropping* a method but not chi *adding* one, which would silently fold onto `(unmatched)`. No clean guard exists while the map stays unexported, so I am waiving this rather than requiring it. - **Sentinel aliasing is fine.** `handler` and `method` are separate label names and never merge series, and by construction `method="(unmatched)"` implies `handler="(unmatched)"` (chi 405s before it matches a pattern). So `(unmatched)`/`(unmatched)` reads as a bad method and `(unmatched)`/`GET` as a bad path — distinguishable, verified live. - Cosmetic, failure-path only: `internal/middleware/metrics_method_test.go:189` `assert.Contains(t, keys(labels), ...)` dumps all 300 tokens when it fails, which is the thing the comment three lines above says the count-based assertion was chosen to avoid. - Disclosure: to confirm fail-first I temporarily reverted the two `props.Method = methodID(...)` lines **in my own throwaway clone**, ran `make test` (both cardinality tests failed at 601 to 3,001 series / 7,501 to 37,501 lines, matching the PR body; the positive control correctly passed both before and after), then restored. Nothing was changed, committed or pushed here. - On the `deliveryDuration` observation: confirmed — `webhooker_delivery_duration_seconds` is genuinely absent from a fresh scrape. Real, out of scope for this PR, and worth a follow-up issue against https://git.eeqj.de/sneak/webhooker/issues/209. Not required here.
clawbot merged commit 763d8f8058 into next 2026-08-24 02:03:19 +02:00
clawbot deleted branch issue-261-metrics-method-label 2026-08-24 02:03:20 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#264