Files
webhooker/internal/server/sentry.go
clawbot fb5a203896
All checks were successful
check / check (push) Successful in 2m49s
Send the chi route pattern to Sentry, not the concrete path (closes #179)
The Sentry SDK builds Request.URL as scheme://host/path from the
concrete path, which on the receiver route is /webhook/<uuid> in full.
That UUID is a write capability, not an identifier: anyone holding it
can post events this service accepts and its targets then deliver. A
third-party tracker has its own retention, access control and deletion
policy, so the rule the local access log follows does not carry across
that boundary.

The BeforeSend hook now rebuilds the URL from the chi route pattern,
on every route rather than by route list, since a route-conditional
rule leaks on any route someone forgets to add. Scheme and host are
kept and everything else in the URL is discarded rather than edited:
the scheme is the CSRF TLS decision the header allowlist relies on,
and the host is whatever the client's Host header carried, which the
allowlisted Host header already sends anyway.

The pattern is reachable only on the error dispatch, where sentryhttp
puts the request on the context the client copies onto the hint. A
finished span captures with a nil hint, so BeforeSendTransaction sees
no context; there and wherever else the pattern is missing the path
falls back to the literal /(redacted), never to the concrete path, and
a URL that will not parse into a scheme is withheld whole. A
transaction event's SDK-built "METHOD /path" name carries the same
capability and is rewritten on the same terms.
2026-08-18 00:35:35 +00:00

237 lines
7.7 KiB
Go

package server
import (
"net/http"
"net/url"
"strings"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi"
)
// sentryRedacted stands in for a withheld field on every event shipped
// to Sentry. It is a marker rather than an empty string so a reader
// can tell a suppressed value from an absent one.
const sentryRedacted = "(redacted)"
// sentryRedactedPath is what stands in for the request path when the
// route pattern is not reachable. It is deliberately not the concrete
// path: on the receiver route that path carries the entrypoint UUID,
// which is a write capability rather than an identifier.
const sentryRedactedPath = "/" + sentryRedacted
// sentryClientOptions builds the options the SDK is initialised with.
// It is its own function so a test can stand up a client wired exactly
// as production is, with only the transport swapped.
func sentryClientOptions(dsn, release string) sentry.ClientOptions {
return sentry.ClientOptions{
Dsn: dsn,
Release: release,
// Both hooks, because the SDK runs one for error events
// and the other for transactions.
BeforeSend: scrubSentryRequest,
BeforeSendTransaction: scrubSentryRequest,
}
}
// scrubSentryRequest strips client-supplied content from an event's
// request context before it leaves the process.
//
// sentryhttp attaches the whole *http.Request to the scope
// (sentryhttp.go:113), and Scope.ApplyToEvent fills the event's
// Request from it inside prepareEvent, which runs before this hook.
// Two of the fields it fills are copied with no SendDefaultPII guard:
//
// - QueryString, verbatim from r.URL.RawQuery.
// - Data, the first 10 KiB of the request body, teed off r.Body by
// SetRequest and filled precisely because the handlers call
// ParseForm.
//
// Since every form field in this service is read with PostFormValue,
// the body is the only place a credential is submitted: a target's
// destination URL, whose path segments are the bearer token, plus the
// login password and both password-change fields. None of that may
// reach a third-party service.
//
// URL is the third such field. NewRequest builds it as
// scheme://host/path (interfaces.go:183), and on the receiver route
// that path is /webhook/<uuid> in full — a write capability, not an
// identifier. It is rebuilt here from the chi route pattern, on every
// route, keeping the scheme and the host.
//
// This hook is a floor, not a default: the fields it clears stay
// cleared even if SendDefaultPII is ever turned on.
func scrubSentryRequest(
event *sentry.Event,
hint *sentry.EventHint,
) *sentry.Event {
if event == nil {
return event
}
pattern := sentryRoutePattern(hint)
// Only transaction events carry a Transaction name, and the SDK
// builds it from the concrete path too (sentryhttp.go:105 via
// tracing.go:553). Rewritten on the same terms.
if event.Transaction != "" {
event.Transaction = sentryTransactionName(
event.Transaction, pattern,
)
}
if event.Request == nil {
return event
}
req := event.Request
if req.URL != "" {
req.URL = sentryRouteURL(req.URL, pattern)
}
if req.QueryString != "" {
req.QueryString = sentryRedacted
}
if req.Data != "" {
req.Data = sentryRedacted
}
req.Cookies = ""
req.Env = nil
req.Headers = keptSentryHeaders(req.Headers)
return event
}
// sentryRoutePattern returns the chi route pattern for the request the
// hint carries, or "" when it is not reachable.
//
// The request is reachable on the error dispatch only. sentryhttp's
// recover path calls RecoverWithContext with the request on the
// context under sentry.RequestContextKey (sentryhttp.go:124-125), and
// the client copies that context onto the hint (client.go:484-485)
// before handing it to BeforeSend (client.go:631). chi's routing
// context is a pointer placed on the request context before the
// middleware chain runs (chi mux.go:84) and filled in as the mux
// routes, so by the time a handler panics it names the matched route.
//
// The transaction dispatch has no such request: Span.doFinish calls
// hub.CaptureEvent (tracing.go:356), which passes a nil hint that the
// client replaces with an empty one (client.go:620-622). The pattern
// is therefore always "" there, and the callers fall back.
func sentryRoutePattern(hint *sentry.EventHint) string {
if hint == nil || hint.Context == nil {
return ""
}
req, ok := hint.Context.Value(
sentry.RequestContextKey,
).(*http.Request)
if !ok || req == nil {
return ""
}
rctx := chi.RouteContext(req.Context())
if rctx == nil {
return ""
}
// Empty when no route matched, which is the fallback case too.
return rctx.RoutePattern()
}
// sentryRouteURL rebuilds an event's request URL with the route
// pattern in place of the concrete path.
//
// The scheme is load-bearing and is kept: the SDK derives it from
// r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
// (interfaces.go:180), byte for byte the predicate
// internal/middleware/csrf.go uses, so it is the CSRF TLS decision and
// the reason dropping X-Forwarded-Proto from the header allowlist
// costs nothing. The host is parsed.Host of the SDK's
// scheme://r.Host/path, so it is whatever the client's Host header
// carried: this service validates no hostname. It is kept because that
// same header is on the allowlist, so scrubbing it here would withhold
// nothing that is not sent anyway.
//
// Everything else in the URL is discarded rather than edited, so a
// future SDK that starts appending a query string cannot widen this.
func sentryRouteURL(rawURL, pattern string) string {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme == "" {
// Not a shape this can safely take apart.
return sentryRedacted
}
if pattern == "" {
pattern = sentryRedactedPath
}
return parsed.Scheme + "://" + parsed.Host + pattern
}
// sentryTransactionName rebuilds the SDK's "METHOD /path" transaction
// name with the route pattern in place of the concrete path. Method is
// kept for the same reason Request.Method is: net/http admits only a
// bounded token there. A name in any other shape is withheld whole,
// since nothing can be said about which part of it is a path.
func sentryTransactionName(name, pattern string) string {
method, _, found := strings.Cut(name, " ")
if !found {
return sentryRedacted
}
if pattern == "" {
pattern = sentryRedactedPath
}
return method + " " + pattern
}
// keptSentryHeaders returns the subset of headers an event may carry
// off-host. Dropping by allowlist rather than by blocklist is what
// makes an unrecognised header safe: the SDK's own filter removes four
// names and passes everything else, so X-Csrf-Token — which
// gorilla/csrf accepts in place of the form field — and the shared
// secrets senders put on the receiver route (X-Gitlab-Token and the
// per-provider signature headers) would otherwise ship verbatim.
func keptSentryHeaders(headers map[string]string) map[string]string {
if len(headers) == 0 {
return headers
}
kept := make(map[string]string, len(headers))
for name, value := range headers {
if sentryKeepsHeader(name) {
kept[name] = value
}
}
return kept
}
// sentryKeepsHeader reports whether a request header is routing or
// content metadata rather than client-chosen payload. Referer is kept
// on the reasoning that it is browser-set, that this service emits
// only ?page= in its own links, and that Referrer-Policy is set to
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
// local access log line, which holds the rest of the detail.
func sentryKeepsHeader(name string) bool {
switch http.CanonicalHeaderKey(name) {
case "Accept",
"Content-Length",
"Content-Type",
"Host",
"Origin",
"Referer",
"User-Agent",
"X-Request-Id":
return true
default:
return false
}
}