Send the chi route pattern to Sentry, not the concrete path (closes #179) #181
Reference in New Issue
Block a user
Delete Branch "issue-179-sentry-route-pattern"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #179.
The defect
scrubSentryRequestredacts the Sentry body, query string, cookies, env and headers, but keptRequest.URL, which the SDK builds asscheme://host/pathfrom the concrete path (interfaces.go:183). On the receiver route that path is/webhook/<uuid>in full, and that UUID is a write capability rather than an identifier.#146's "2xx and 5xx keep the concrete path" ruling was reasoned about a log the operator owns and does not transfer to a tracker with its own retention, access control and deletion policy.
Unconditional, on every route
The pattern replaces the path on every route, not on a sensitive-route list. #174's reasoning for redacting the body unconditionally applies unchanged: a route-conditional rule leaks on any route someone forgets to add. There is no counterweight here — unlike the body, the rewrite costs nothing on the routes it is not aimed at, since on a static route the pattern is the path (
/pages/loginin,/pages/loginout, asserted byTestSentryScrub_KeepsTheRoutingContext).The route is still identifiable
Sentry groups an error event by its exception and stack trace;
Request.URLis displayed, not a default grouping component. Replacing the concrete path with the pattern therefore changes nothing about grouping and still names the route in the UI — while lowering the cardinality of what is displayed, which is what Sentry's ownSourceURL/SourceRoutedistinction exists for.Scheme and host
Scheme survives, asserted in three places (
http://example.com/webhook/{uuid},http://example.com/pages/login,https://example.com/(redacted)in the fallback cases). This is load-bearing exactly as #174 recorded:interfaces.go:180derives it fromr.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", byte-identical tointernal/middleware/csrf.go:19, so the scheme is the CSRF TLS decision and the allowlist's justification for droppingX-Forwarded-Protostill holds.Host stays — but not because it is operator configuration. It is
parsed.Hostof the SDK-builtscheme://r.Host/path, so it is whatever the client'sHostheader carried: this service validates no hostname (no host allowlist, no configured hostname ininternal/config, andinternal/handlers/source_management.go:402already takesr.Hostat face value for the displayedBaseURL). It is kept because that same header is on the allowlist, so scrubbing it out of the URL would withhold nothing that is not sent anyway. Rev 2 rewords both statements of this rationale —README.mdand thesentryRouteURLdoc comment — off the false premise; the decision itself is unchanged.Everything else in the parsed URL is discarded rather than edited — the result is rebuilt as
scheme + "://" + host + pattern— so an SDK version that starts appending a query string cannot widen this. A side effect worth stating so it is not removed by accident: rebuilding fromparsed.Scheme/parsed.Hostalso strips URL userinfo, sohttp://user:pw@example.com/...becomeshttp://example.com/(redacted).The absent case
The pattern is reachable only on the error dispatch:
sentryhttp.go:124-125callsRecoverWithContextwith the request undersentry.RequestContextKey,client.go:484-485copies that context onto the hint,client.go:631hands it toBeforeSend. chi's routing context is a pointer put on the request context before the middleware chain runs (chi mux.go:84) and filled in as the mux routes, andmx.pool.Puthappens only after the chain returns — so the deferred recover still reads a live pattern.The fallback is never the concrete path:
scheme://host/(redacted)ContextRoutePattern() == "")(redacted), whole/(redacted)reuses the existing marker so a reader can tell a withheld path from an absent one.BeforeSendTransactionIt does not get a usable hint.
Span.doFinishcallshub.CaptureEvent(event)(tracing.go:356), which passes a nil hint toclient.CaptureEvent, which replaces it with an empty&EventHint{}(client.go:620-622). Sohintis non-nil buthint.Contextis nil and no request is reachable — the fallback above is what applies there, verified end to end rather than argued (TestSentryScrub_RedactsTheTransactionDispatchruns a real transaction through the real middleware with tracing switched on).A transaction event also carries
event.Transaction, whichsentryhttp.go:105builds asfmt.Sprintf("%s %s", r.Method, r.URL.Path)andtracing.go:553copies onto the event — the same capability by a second field, in the same hook. It is rewritten on the same terms:METHOD+ pattern where known,METHOD /(redacted)where not, and withheld whole if the name has no space to split on. Method is kept for the reasonRequest.Methodalready is: net/http admits only a bounded token there.sentryTransactionNamecuts on the first space, so a name that is notMETHOD /pathand contains a space becomesfirstWord /(redacted)— safe, and unreachable today since onlysentryhttpsets the name.The service does not enable tracing, so no transaction event is produced today. The hook is a floor against that changing, which is why it is installed on both dispatches.
Tests
internal/server/sentry_test.go, through the realSetRequest→ApplyToEvent→BeforeSendpath. The capture helper now routes through a real chi mux carrying the production middleware order — a recovering middleware inmiddleware.Recoverer's slot, thensentryhttp.New(sentryhttp.Options{Repanic: true}).Handleregistered withUse, exactly asroutes.goregisters it — over/pages/loginand/webhook/{uuid}. That is load-bearing, not decoration: a hand-built request carries no route pattern at all and could not tell the hook working from the hook falling back.TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern— receiver UUID absent from the marshalled event; URL equalshttp://example.com/webhook/{uuid}.TestSentryScrub_SDKCollectsTheRequestUnscrubbed— extended to pin the new premise: unscrubbed,Request.URLdoes contain the UUID.TestSentryScrub_RedactsTheTransactionDispatch/_TransactionDispatchIsUnscrubbedWithoutTheHook— the transaction dispatch, with and without the hook.TestSentryScrub_FallsBackWithoutARoutePattern— four sub-cases (no hint, no context, no request, unrouted request).TestSentryScrub_WithholdsUnparseableValues— schemeless URL and space-less transaction name.Mutation checks
1. The URL rewrite removed (
if req.URL != "" && false). Four tests failed; the marshalled receiver event, straight from the failure output:Note the transaction name still read
"transaction":"POST /(redacted)"in that run — the two rewrites are independently covered.2. The transaction-name rewrite removed (
if event.Transaction != "" && false), URL rewrite restored:Both restored afterwards; the committed tree is the green one.
Gates (rev 2,
fb5a203)make checkexits 0, zero(cached): all 13 packages executed with real durations (internal/ciscript 7.133s,internal/handlers 6.616s,internal/delivery 4.562s,internal/server 3.285s, ...), 0 FAIL, all 13TestSentryScrub_*green. Lint:0 issues.docker build --no-cache-filter=lint --no-cache-filter=builder .exits 0:All 13 packages executed in the container with real durations and zero
(cached), 619PASSlines, 0 FAIL, all 13TestSentryScrub_*green. Lint ran in the pinnedgolangci/golangci-lint:v2.12.2image. The log did not clip at BuildKit's 2 MiB limit this run (1.0 MiB).Disclosure: five layers reported
CACHED— the two base-imageFROMpulls (#8,#9) and three layers of the final Alpine runtime stage (#28–#30). No layer that runs a check was cached. The tagged image was removed anddocker ps -ais empty; no prune of any kind was run.Also touched
README.md— the Sentry paragraphs of the logging section, rewritten to state the URL handling exactly: what is sent, that scheme and host are kept and why, that the rules are unconditional, that the pattern is reachable on the error dispatch only, what the fallback is, and the transaction-name handling. The allowlist paragraph's "scheme, host, path, method" now reads "route pattern" and itsX-Forwarded-Protoreasoning is tied to the scheme the rewrite preserves. Formatted withmake fmt.TODO.mduntouched.Noted, not fixed
The pinned linter still emits
The linter 'gomodguard' is deprecated (since v2.12.0) ... Replaced by gomodguard_v2on every run. Pre-existing, tracked at #98.FAIL — needs-rework
Reviewed at
5ff7cdb, fresh clone, against #179.Finding — the only blocker
A security rationale stated twice that is not true of the code.
README.md:1052-1054— "The host is operator configuration rather than anything a client chooses"internal/server/sentry.go:155-156— "it is operator configuration, not a client-supplied or capability-bearing value"The host in the rebuilt URL is
parsed.Hostof the SDK-builtscheme://r.Host/path(interfaces.go:183), andr.Hostis the client'sHostheader / request-line authority. This service validates no hostname: there is no host allowlist or configured hostname anywhere underinternal/(internal/confighas no host key), andinternal/handlers/source_management.go:402already takesr.Hostat face value to build the displayedBaseURL. Behind a reverse proxy that rewritesHostthe claim holds; on a directly-exposed deployment the client sets that value and it is reflected verbatim into the Sentry URL.Why it matters: this is the stated justification for keeping a field in a hook whose whole job is deciding what may cross a trust boundary. A future reader carries the premise ("host is operator config") to the next field and it is wrong. The decision to keep the host is correct and must not change — the second half of the same sentence is the justification that actually holds.
Acceptable: reword both places to rest on the true reason — the host is whatever
r.Hostcarried and is already shipped by the allowlistedHostheader, so dropping it from the URL would withhold nothing that is not sent anyway. Drop or qualify "operator configuration, not client-chosen". No behaviour change, no test change.Notes and disclosures — not blocking
POST /(redacted)andRequest.URLtoscheme://host/(redacted): the pattern is never reachable on that dispatch (confirmed — nil hint attracing.go:356, replaced by&EventHint{}atclient.go:620-622). Sentry groups transactions by name, so that is one bucket for the whole service. The PR is explicit this is a floor and tracing is off, so the DoD's "route still identifiable" is met on the error dispatch; recording it so enabling tracing later is not done blind. RelatedlyREADME.md:1063"POST /webhook/{uuid}where the pattern is known" describes a state unreachable today.sentryTransactionNamecuts on the first space, so any non-METHOD /pathname containing a space becomesfirstWord /(redacted)(probed:my custom transaction <uuid>→my /(redacted)). Safe, and unreachable today since onlysentryhttpsets the name.parsed.Scheme/parsed.Hostalso strips URL userinfo —http://user:pw@example.com/...→http://example.com/(redacted).go testoutside the make targets, on a scrap copy and not on the reviewed tree; nothing was written to the repo. Gate evidence below is make/Docker only.No concrete path survives, and no third field carries it
14 probes through a real chi mux with real
sentryhttp, all clean: 404 NotFound panic, 405 MethodNotAllowed panic, panic in aUsemiddleware ahead of routing, panic in aGroupmiddleware after routing,CaptureExceptionandCaptureMessagecalled directly from a handler,Mountof a subrouter,Mountof a bare handler, wildcard/static/*, tracing on with and without a panic, and nine URL shapes (IPv6, explicit port, empty host, userinfo, query, fragment, uppercase scheme, scheme-relative, unparseable). Every one yielded the pattern or/(redacted), never the concrete path. A panic in a middleware registered ahead ofsentryhttpproduces no event at all. The wildcard returns the literal/static/*and never the matched remainder — chicontext.go:122-135joins registered patterns only.Third field: none. The full marshalled transaction event carries
contexts.tracewith onlyop/span_id/trace_id— nodescription, because the SDK never setsSpan.Descriptionfor the rootsentryhttptransaction — and no spans, breadcrumbs, tags, extra or fingerprint bearing the path.event.Transactionis assigned in exactly one place in the SDK (tracing.go:553).Mutations reproduced independently: URL rewrite disabled → 4 tests fail including
_ReplacesTheCapabilityPathWithTheRoutePattern; transaction-name rewrite disabled → 3 tests fail and_ReplacesTheCapabilityPathWithTheRoutePatternstill passes. Separately covered, confirmed.Gate evidence
make checkexit 0, zero(cached).docker build --no-cache-filter=lint --no-cache-filter=builder .exit 0:13 packages with real durations (
internal/ciscript 7.119s,internal/handlers 5.473s,internal/delivery 4.065s,internal/server 2.909s, ...), zero(cached), 725 PASS lines, all 13TestSentryScrub_*green, 0 FAIL. No layer that runs a check was CACHED. Tagged image removed,docker ps -aclean, no prune of any kind.Also verified: CI green on
5ff7cdb; merges clean intonext(76725cf); exactly one commit; title ends(closes #179);TODO.mduntouched; no Claude/Anthropic references or attribution trailers;make fmta no-op; inclusive terminology; scheme preserved and asserted in three tests; chi import path consistent at v1.5.5 repo-wide. Thegomodguarddeprecation warning is pre-existing and tracked at #98.5ff7cdbdb5tofb5a203896