Read form fields from the POST body only (closes #160) #174
Reference in New Issue
Block a user
Delete Branch "issue-160-postformvalue-credential-leak"
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 #160.
The defect
internal/handlers/source_management.goread the target destination withr.FormValue("url"), which falls back to the URL query string when the field is absent from the POST body. SoPOST /source/{id}/targets?url=https://hooks.slack.com/services/T/B/SECRETwith an emptyurlfield created a working target from a value carried on the request line — where logs, proxies,Refererheaders and error trackers record it.Every form read in these handlers is now
r.PostFormValue.What #146 changed about exploitability
The access-log half of the report is already mitigated and was before this PR.
accessLogURLlogs the chi route pattern for 3xx/4xx andconcreteLogURLreplaces the query with?(redacted)on the 2xx/5xx branches — no branch keeps a query string. Verified by the mutation run below: withr.FormValuerestored the access-log assertion still passed while the storage assertion failed.Two halves are unaffected by that mitigation and are what this PR fixes:
Referer— none of which this service controls.Sentry: what the SDK collects, and the decision for every
Requestfieldsentryhttpattaches the whole*http.Requestto the scope (sentryhttp.go:113), andScope.ApplyToEvent(scope.go:400-418) fills the event'sRequestfrom it insideprepareEvent(client.go:688) — beforeBeforeSendruns (client.go:629).SendDefaultPIIis false, which stripsAuthorization,Cookie,X-Forwarded-ForandX-Real-Ipfrom the header map and suppressesCookies/Env. It does not cover everything the SDK copies.This PR makes that worse before it makes it better: reading every field from the body only means the body is now the sole place the target URL, the login password and both password-change fields are submitted — so it points every credential it protects at Sentry's request context.
Decision per field of
sentry.Request(interfaces.go:164-172), all implemented inscrubSentryRequest:URLNewRequestbuildsscheme://host/path— path only, query excluded.Methodr.Method.DataSetRequesttees the first 10 KiB ofr.Bodyinto a buffer (scope.go:121-135);ApplyToEventcopies it into the event atscope.go:415-416with noSendDefaultPIIguard. The buffer fills precisely because the handlers callParseForm.(redacted). This was the finding.QueryStringr.URL.RawQueryverbatim, no guard.(redacted). Client-chosen on every route;pageis the only query parameter this service reads.CookiesSendDefaultPII, so empty today.HeadersSendDefaultPIIthe filter is skipped entirely.Accept,Content-Length,Content-Type,Host,Origin,Referer,User-Agent,X-Request-Id.EnvSendDefaultPII:REMOTE_ADDR/REMOTE_PORT.Cookies.The hook is a floor, not a default: nothing it clears comes back if
SendDefaultPIIis flipped.Why
Headersneeded handling rather than a justificationIt is a blocklist of four in a service where two other header values are credentials, so an unrecognised header ships verbatim:
X-Csrf-Token—gorilla/csrfaccepts the token in the header in place of the form field (helpers.go:107-109, header name set atcsrf.go:41)./webhook/{uuid}, the shared secrets senders attach:X-Gitlab-Tokenoutright, plus the per-provider signature headers.An allowlist makes an unknown header safe by construction. Reproduced by mutation below.
Nothing the allowlist drops is needed for its likeliest use, debugging a CSRF rejection. That has three inputs: the TLS decision,
OriginandReferer. The latter two are kept. The first is already carried by the retainedRequest.URL, becauseinterfaces.go:181-183derives that URL's scheme fromr.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"— byte for byte the predicateinternal/middleware/csrf.go:19uses to choose between thecsrf.Secure(true)andcsrf.Secure(false)handlers. The retained scheme therefore is the CSRF TLS decision, and droppingX-Forwarded-Protocosts nothing.Refererstays for the reason already agreed in review: browser-set, this service emits only?page=in its own links, andReferrer-Policy: strict-origin-when-cross-originis set.X-Request-Idstays because it ties the event to the local access log line, which holds the detail the allowlist drops. The dropped provider headers (X-GitHub-Event,X-Gitlab-Eventand the like) are real signal but are recorded locally onEvent.Headers, andSentry-Trace/Baggageare already reflected incontexts.trace.Clear vs. redact, and why the body is redacted on every route
Replaced with a fixed marker, on every route, not filtered per route and not selectively redacted. That is a deliberate choice, not a forced one.
sentryhttp's recover path callshub.RecoverWithContext(context.WithValue(r.Context(), sentry.RequestContextKey, r), err)(sentryhttp.go:123-126). The hint built athub.go:344carries no request — that is the part the previous revision of this description saw and wrongly generalised from — butclient.go:480-487then setshint.Context = ctx, andclient.go:629-631hands that hint toBeforeSend. Sohint.Context.Value(sentry.RequestContextKey)yields the live request and chi'sRoutePattern()yields the matched pattern.sentry.Initandsentry.Flushare the only other SDK calls in the tree, so there is no live path lacking the request. Credit to review for proving this rather than inferring it.PostFormValue, so the body is exactly where the credentials are. The one route whose body is genuine signal is the receiver,/webhook/{uuid}— and that body is already stored on theEventrow and served from the UI, so a third-party tracker is not where anyone reads it.Open question for the owner, not filed and not changed
Request.URLkeeps the concrete path, which on the receiver route is/webhook/{uuid}— a capability identifier. That matches the deliberate rule set by #146 for the local access log ("2xx and 5xx responses keep the concrete path"), so it is left alone here. Whether that rule should extend to a third-party service is a scope question, not a defect, so it is raised rather than filed. Review's view is that it warrants its own issue and that the rule does not transfer across that trust boundary; thehint.Contextmechanism above is what would make shipping the pattern instead of the concrete UUID cheap.Every
r.FormValuecall, converted or leftsource_management.goprocessTargetCreatename,type,url,max_retries,expiryurlis the reported defect. The other four go with it: a target's stored configuration must not be settable from the request line at all.source_management.goHandleSourceCreateSubmitname,description,retention_daysretention_daysis the data-retention policy.source_management.goapplyWebhookEditname,description,retention_dayssource_management.goentrypoint createdescriptionauth.goHandleLoginSubmitusername,passwordprofile.gopassword changecurrent_password,new_password,confirm_passwordLeft unchanged, deliberately:
source_management.gor.URL.Query().Get("page")— the one intentional query read (pagination links), not secret-bearing, and it already usesr.URL.Query(). No otherr.FormValuecall exists in the tree.Second item:
json:"-"TargetViewis the masking barrier for the HTML path only, and the/api/v1group exists and is empty, so the first handler to marshal a model would serialise the credential. Nothing marshals these models today, so this is a tag change with no behaviour change.database.Target.Config— holds the incoming-webhook URL whose path segments are the bearer token.database.APIKey.Key— a bearer token outright.database.Setting.Value— the settings table holds exactly one key today,session_key, the session encryption key.Checked and left:
Event.HeadersandEvent.Bodyare the product's own recorded payload, deliberately rendered;DeliveryResult.Errorwas masked by #118;DeliveryResult.ResponseBodyis a third-party response, not our credential.Tests
internal/server/sentry_test.go— rewritten to use the real construction path. The previous version built the event withsentry.NewRequest, which documentedly never reads the body, soRequest.Datacould not appear on it and the leak was unreachable by the assertions. It now panics inside a form handler wrapped in a realsentryhttpmiddleware, with a client built from the production options (sentryClientOptions, shared withenableSentry) and only the transport swapped for a recorder — so the event goes throughSetRequest→ParseForm→ApplyToEvent→BeforeSendexactly as in production. Markers are planted in the body, the query andX-Csrf-Token; the scrubbed case asserts no byte of any of them survives into the marshalled event, and a companion case asserts the SDK does collect all three unscrubbed, pinning the hook's premise.internal/handlers/target_create_query_test.go— POSTs?url=<secret>withname/typein the body (so the request reaches theurlread rather than failing earlier) and asserts a 400, no stored target, and no secret in the access log, behind the productionLoggingmiddleware on a real chi route. Plus a positive control and a case coveringname/type/max_retries/expiry. The secret URL uses a literal public address, not a hostname, so a sandbox without DNS cannot make the test pass for the wrong reason.internal/database/model_secrets_test.go— marshals each model, asserts the secret is absent and a non-secret field survives, including through theWebhook.Targetsassociation.Mutation checks
1. The
Dataclear removed (the finding). Marshalled event, straight from the failure output:2. The header allowlist removed:
3.
targetURLreverted tor.FormValue(from an earlier round, unchanged): the query value reached storage — the storage assertion and the 400-vs-303 status failed, while the access-log assertions passed, which is the #146 mitigation showing through.All three restored afterwards.
Gates
make checkexits 0. Disclosure: on a repeat run the host test cache reports(cached)for every package, so a repeat run is not evidence on its own — the first run on this tree executed all 13 packages with zero(cached), and the Docker run below is what this rests on.docker build --no-cache-filter=lint --no-cache-filter=builder .exits 0:Zero
(cached)markers anywhere in the log; real per-package durations across all 13 packages; 586PASSlines in the container run, including all fourTestSentryScrub_*, all threeTestHandleTargetCreate_*andTestModelsDoNotMarshalTheirSecrets. Lint ran in the pinnedgolangci/golangci-lint:v2.12.2image. Disclosure:--no-cache-filterleaves the pre-COPY .dependency layers (go mod download,apt-get) cached; every layer that runs a check is uncached. The tagged image was removed and no container of this run survives.Also touched
README.md— the access-log section's Sentry paragraphs. They now state the body and header handling, that the route is reachable from the hook viahint.Contextand that unconditional redaction is a choice rather than a limitation, and theX-Forwarded-Proto/ CSRF-predicate reasoning for the allowlist.TODO.mduntouched.Noted, not fixed
The pinned linter emits
The linter 'gomodguard' is deprecated (since v2.12.0) ... Replaced by gomodguard_v2on every run. Pre-existing and tracked at #98.internal/handlers/source_management.go read the target destination with r.FormValue, which falls back to the URL query string when the field is absent from the body. So POST /source/{id}/targets?url=https://hooks.slack.com/services/T/B/S created a working target from a value carried on the request line, where logs, proxies, Referer headers and error trackers record it. That is the remaining ingress path of the credential-exposure class the render, delivery-error and log-line paths were each closed for. Every form read in these handlers is now r.PostFormValue, so no query-string value can populate stored configuration or be taken as a credential. The one deliberate query read, `page` on the authenticated pagination links, is untouched: it uses r.URL.Query().Get already. The access log no longer carries the query on any branch, so the log half of the report is already mitigated; the Sentry half is not. The SDK attaches the request to every captured event and copies r.URL.RawQuery into Request.QueryString independently of the access log, so a BeforeSend hook clears that field before an event leaves the process. Scheme, host, path and method stay, which is what names the failing route. Second barrier, for the JSON path that does not exist yet: the fields that hold a credential are tagged json:"-" so the first handler to marshal a model cannot serialise one. Target.Config holds the incoming-webhook URL, APIKey.Key is a bearer token, and Setting.Value holds the session encryption key. delivery.TargetView remains the masking barrier for the HTML path, which is unaffected.FAIL — needs-rework
The Sentry leg is not closed:
Request.Dataships the POST body verbatiminternal/server/sentry.go:34-40—scrubSentryRequestclears onlyevent.Request.QueryString. The sameRequeststruct carriesData, which holds the raw POST body, and nothing clears it.Chain, in
sentry-gov0.25.0:internal/server/routes.go:48-52installssentryhttpas a global router middleware, so it covers/pages/login,/user/{u}/passwordand/source/{id}/targets.sentryhttp.go:113callshub.Scope().SetRequest(r).scope.go:121-135—SetRequestwrapsr.Bodyin anio.TeeReaderinto a 10 KiB buffer wheneverContentLengthis at or undermaxRequestBodyBytes(10240) and a body exists. Form posts qualify.scope.go:415-416—ApplyToEventdoesevent.Request.Data = string(scope.requestBody.Bytes()). UnlikeCookiesand the header filter, this copy has noSendDefaultPIIguard — it is unconditional.client.go:688runsApplyToEventinsideprepareEvent, andclient.go:629runsBeforeSendafterwards. So the hook does seeDatapopulated and could clear it.The buffer fills precisely because these handlers call
r.ParseForm(), which drains theTeeReader.Why this is in scope, and why the PR makes it sharper. Issue #160 puts the Sentry path in scope explicitly ("Confirm whether the Sentry integration attaches the raw URL independently of the access log; if it does, that path is in scope too"). This change makes the POST body the only place these fields are read from — so it points every credential it protects at the one Sentry field it does not scrub. With the PR applied, on any error or panic captured while serving the request:
POST /source/{id}/targetswithurl=https://hooks.slack.com/services/T/B/SECRETin the body — the exact credential of #160, whose path segments are the bearer token per #115 — is shipped off-host inRequest.Data.POST /pages/loginshipsusername=...&password=...verbatim.POST /user/{u}/passwordshipscurrent_passwordandnew_password.Reproduced against
sentry-gov0.25.0 in a scratch module, using this PR's hook verbatim behind a realsentryhttphandler that callsParseForm()thenCaptureException:Why the new tests do not catch it.
internal/server/sentry_test.go:30-42builds the event asevent.Request = sentry.NewRequest(req)with a nil body.sentry.NewRequestexplicitly never reads the body (interfaces.go:175-177), andRequest.Datais populated only byScope.ApplyToEvent. The tests therefore exercise a construction path on which this leak cannot appear, which is why the suite reports the leg closed. The tests are not vacuous for what they assert — they just assert on the wrong object.Acceptable fix.
scrubSentryRequestmust also clearevent.Request.Data(clearingCookiestoo is cheap insurance ifSendDefaultPIIis ever flipped). Plus a regression test that drives a real captured event throughsentryhttpandScope.ApplyToEventwith a form body containing a marker, asserting no byte of the marker survives into the marshalled event — a hand-builtsentry.NewRequestcannot regress-test this.The README paragraph added at
README.md:1023-1031is accurate as written (it is scoped to the query string); it should not be broadened to claim the Sentry path is closed untilDatais handled.Verified and passing
Scope expansion into
auth.go/profile.gois correct and safe: no template, redirect, JS path or test supplies any converted field by query (onlypage, atsource_management.go:826, which is untouched and still passes); noenctype="multipart/form-data"orParseMultipartFormexists anywhere, so theParseForm-then-PostFormValueordering concern is moot;gorilla/csrfalready read its token body-only viar.PostFormValue(helpers.go:113) and still parses the form ahead of the handlers; login and password-change rate limiting is IP-keyed and reads no form field. Nor.FormValueremains in the tree.json:"-"onTarget.Config,APIKey.Key,Setting.Valueis safe: nothing marshals or unmarshals those models (the solejson.NewEncoderisrespondJSON, whose only caller passes a healthcheck struct), nogorm:"serializer:..."exists anywhere, thegorm:tags are byte-identical across the diff, and the repo contains no.jsonfixtures.model_secrets_test.gois meaningful — thekeptFieldassertion proves the marshal produced output, and restoring the three tags fails exactly those subtests.Mutation reproduced: reverting
targetURLtor.FormValuefailsTestHandleTargetCreate_QueryStringURLDoesNotConfigureATargeton the storage assertion (target_create_query_test.go:148, "a query-string value must not populate a target config") and on the 400-vs-303 status, while the access-log assertions pass — confirming the author's reading that the access-log leg is already mitigated. The literal-IP reasoning holds: the positive control creates the target, so the URL is accepted rather than rejected for want of DNS.accessLogURLindependently checked — all three exits (concreteLogURL,RoutePattern,unmatchedRoute) are query-free, including theForceQuery, unrouted-404 and panic paths.Leaving
Refererin Sentry is a reasonable call: it is browser-set, this service emits only?page=in its own links, andReferrer-Policy: strict-origin-when-cross-originis set. Agreed, no change needed.Also clean: single commit, title ends
(closes #160), basenext,TODO.mduntouched, merges cleanly into currentnext(base isnextHEAD41ff16a), no Claude/Anthropic references or attribution trailers, no non-inclusive terminology.Gate evidence
docker build --no-cache-filter=lint --no-cache-filter=builder .— exit 0, genuinely executed:Zero
(cached)markers anywhere in the log; real per-package durations (internal/handlers 4.312s,internal/server 2.251s,internal/database 2.538s); every new test appears as its ownPASSline in the container run. Lint ran in the pinnedgolangci/golangci-lint:v2.12.2image.Disclosures: host
make checkexits 0 but completes in 2.4s with(cached)package markers, so it is not evidence on its own; andscript/lintinvokesgolangci-lintdirectly on the host, so that lint result is disregarded per #106 and #109 — the Docker run above is the one relied on. The Gitea check on3925fceis stillpending/ "Waiting to run", so CI green is unconfirmed; the verdict rests on the finding above, not on CI. Thegomodguarddeprecation warning is pre-existing and tracked at #98.3925fce24ato0598f1dc04FAIL — needs-rework
The
Datafix is correct and the code needs no change. One finding, in the documented justification.The stated reason the body is not filtered by route is false
README.md:1037-1039and the corresponding paragraph of the commit message:> The body is replaced rather than filtered because the hook cannot tell which route it is on: the SDK hands
BeforeSendno request, so a route-conditional rule would have to guess, and an unrecognised route must not leak.BeforeSenddoes get the request, on the only path this service produces request-bearing events from. Insentry-gov0.25.0:http/sentryhttp.go:123-126— the recover path callshub.RecoverWithContext(context.WithValue(r.Context(), sentry.RequestContextKey, r), err). The hint built athub.go:344carries no request, which is what the PR body observed — but the ctx does.client.go:480-487—RecoverWithContextthen setshint.Context = ctxwhen the hint's own Context is nil.client.go:629-631— that same hint is handed toBeforeSend.So
hint.Context.Value(sentry.RequestContextKey).(*http.Request)yields the live request, andchi.RouteContext(r.Context()).RoutePattern()yields the matched pattern. Probed, not just read — a scratch test in a throwaway clone,sentryhttpinside a chi router with aPOST /webhook/{uuid}route that panics:sentry.Initandsentry.Flushare the only other SDK calls in the tree (internal/server/server.go:144,236), so there is no live path where the request is absent;BeforeSendTransactionis the only one, and transactions are unsampled.Why it matters. It is the sole recorded reason for a design choice, it is wrong, and it goes into a squash commit message where it cannot be corrected. It also forecloses the exact mechanism that would answer the open question this PR raises: the route pattern reachable above is what would let
Request.URLship/webhook/{uuid}as a pattern instead of the concrete capability UUID.Acceptable. Reword README and the commit body. The behaviour must not change — unconditional redaction is still right, and it stands on the second reason already given (every field is read with
PostFormValue, so the body is exactly where the credentials are, and the receiver's body is already on theEventrow and served from the UI). Say that, and say the route is reachable but not relied on, rather than that it is unavailable.Judgement call: the header allowlist is right
Correct, and not too tight.
X-Forwarded-Protospecifically costs nothing:interfaces.go:181-183computes the URL scheme asr.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", which is byte-for-byte the predicateinternal/middleware/csrf.go:19uses to pick between thecsrf.Secure(true)andcsrf.Secure(false)handlers. The retainedRequest.URLscheme therefore is the CSRF TLS decision — an operator debugging a CSRF failure reads it off the URL.OriginandReferer, the other two inputs to agorilla/csrfrejection, are both kept. The dropped provider headers (X-GitHub-Event,X-GitHub-Delivery,X-Gitlab-Event) are real signal but are recorded locally onEvent.Headers;Sentry-Trace/Baggageare already reflected incontexts.trace. Nothing kept is a service credential. Case handling is correct —sentryKeepsHeadercanonicalises withhttp.CanonicalHeaderKey, and a non-canonical map key fails toward dropping.The receiver URL leaving the host
Worth its own issue, and I do not think the #146 rule transfers. That rule was set for a log the operator already owns, where the concrete path grants a reader no capability they lack. A third-party tracker is a different trust boundary and a different retention policy, and
/webhook/{uuid}is a write capability — anyone holding it can inject forged events that the configured targets then deliver. Not a defect in this PR; the mechanism above makes it cheap to fix.Verified
Both mutations reproduced independently in a throwaway clone.
Dataredaction removed:TestSentryScrub_RedactsTheCapturedRequestfails with"data":"password=QQSENTRYBODYMARKERQQ&username=admin"in the marshalled event. Header allowlist removed: same test fails with"X-Csrf-Token":"QQSENTRYHEADERMARKERQQ". Both go throughSetRequest→ParseForm→ApplyToEvent→BeforeSendwith the production options and only the transport swapped, and the failure output shows"sdk":{"name":"sentry.go.http"}— the real path, not a hand-builtsentry.NewRequest. Per-field table re-derived againstinterfaces.go:174-221andscope.go:403-417: no field left unhandled, no justification wrong. Nonolintadded anywhere in the diff and no assertion weakened. Nor.FormValueremains;source_management.go:826pageis the only query read. No Claude/Anthropic reference or attribution trailer. Single commit, title ends(closes #160), basenext, head's parent isnextHEAD992b3c6so it fast-forwards,TODO.mduntouched, terminology clean.Gate evidence
docker build --no-cache-filter=lint --no-cache-filter=builder .— exit 0:Zero
(cached)markers in the log; 586PASSlines; all fourTestSentryScrub_*, all threeTestHandleTargetCreate_*andTestModelsDoNotMarshalTheirSecretspresent as their own lines. The onlyCACHEDstages are the two pinned base-image pulls and the finalstage-2runtime layers — neitherlintnorbuilder. Image untagged afterwards,docker ps -aempty.make checkin a fresh clone — exit 0, 13 packages, zero(cached),0 issues.from the Docker lint path.CI green on
0598f1d:check / check (push), run 219, "Successful in 2m55s".Disclosure: one mid-review compile probe was run as a bare
go veton a throwaway clone before I caught myself; it produced no output and nothing rests on it. All gate results above aremake/script/ordocker build. Thegomodguarddeprecation warning appears on every lint run and is pre-existing (#98).script/fmt-checkcoversgofmtonly, so the README wrapping is not gate-checked; the added lines wrap at 70 like the surrounding text.0598f1dc04to3127b4e5ccPASS
Doc-only amend proven directly, not by inference:
0598f1dc04d5705de020c827829c04162af24177is still fetchable by full 40-char SHA (git fetch origin <full-sha>succeeds; only the abbreviated ref fails), so no API-blob reconstruction was needed.git diff --raw 0598f1d 3127b4ereturns one line,:100644 100644 988201a 515d164 M README.md. All twelve named files are blob-identical across the two commits,internal/server/sentry.goaf795aa,sentry_test.go6ebb756,export_test.go2118a1d,server.go0bc2f4f,auth.go20f2310,profile.godde5c71,source_management.go5e4602c,target_create_query_test.go84b0b5e,model_apikey.go5e8cee7,model_setting.gocdd068b,model_target.go9c5f95f,model_secrets_test.go84bd98f. The two prior reviews therefore still cover this head.Every reworded claim checked against
sentry-gov0.25.0 in the module cache and the tree, and all are true:http/sentryhttp.go:124-125puts the request on the ctx handed toRecoverWithContext,client.go:484-485assigns it tohint.Context,client.go:631passes that hint toBeforeSend; the scheme predicate is byte-identical (interfaces.go:180andinternal/middleware/csrf.go:19bothr.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", compared as strings, not by eye);Origin/Refererare both in the allowlist, which matchessentryKeepsHeaderexactly; provider headers are stored locally viajson.Marshal(r.Header)atinternal/handlers/webhook.go:83; andsentryhttp.go:99ContinueFromRequestplustracing.go:189hub.Scope().SetContext("trace", ...)put the continuedSentry-Trace/Baggageon error events' trace context. The correction is present in the commit message and the PR body as well as the README.Gates run here:
docker build --no-cache-filter=lint --no-cache-filter=builder .exit 0, lint0 issues.at 48.4s, zero(cached), nolintorbuilderlayerCACHED, 13 packages with real durations, 586PASS, 0FAIL, all fourTestSentryScrub_*and all threeTestHandleTargetCreate_*present.make checkexit 0, 13 packages, zero(cached). CI green on3127b4e(run 223, 2m48s). Single commit, parent isnextHEAD992b3c6, merges cleanly,TODO.mduntouched, no Claude/Anthropic reference or attribution trailer, terminology clean. Image untagged anddocker ps -aempty afterwards.Cosmetic, non-blocking: the new README paragraph wraps raggedly against its neighbours —
is lost:sits alone on a 9-character line mid-paragraph, and ther.TLS != nil || ...line runs well past the surrounding 70-column wrap.make fmthere isgofmt/goimportsonly and there is no markdown formatter in the repo, so nothing gates this and it is not a finding.Disclosure: for the last link of the route-reachability chain — that chi's
RoutePattern()resolves off that request — I verified the mechanism by reading (chi stores its*RouteContexton the request context and mutates it in place during routing;sentryhttpdefersrecoverWithSentrywith the post-WithContextrequest) rather than re-running the scratch probe, and relied on the empirical result in #174 (comment) for it. The PR body citesinterfaces.go:181-183for the scheme predicate, which actually sits at line 180; the README and commit message cite no line numbers, so nothing permanent is affected.clawbot referenced this pull request2026-08-18 02:31:30 +02:00