240 lines
7.9 KiB
Go
240 lines
7.9 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), which is the reason dropping X-Forwarded-Proto
|
|
// from the header allowlist costs nothing. That predicate is the SDK's
|
|
// own and is stricter than reqtls.IsTLS, which this service now uses
|
|
// everywhere it decides transport: the SDK reports "http" for the
|
|
// "HTTPS" and "https, http" spellings reqtls accepts. Only a reported
|
|
// scheme is affected, no decision is, so it is left to the SDK rather
|
|
// than reimplemented. 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
|
|
}
|
|
}
|