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/ 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 kept because it names the deployment the // event came from and is already carried by the allowlisted Host // header; it is operator configuration, not a client-supplied or // capability-bearing value. // // 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 } }