check / check (push) Waiting to run
A Sentry event kept the Referer header verbatim, and a Referer can be an entrypoint URL: a sender that follows the README's http:// to https:// redirect sends the URL it was redirected from, and a browser sends the 404 page's URL under /h/ when it fetches that page's stylesheet. The Sentry hook now keeps only the Referer's scheme and host. The access log's reduction moves, unchanged, into a new package, internal/referer, as SchemeAndHost; the access log and the Sentry hook both call it there. The comment above sentryKeepsHeader and the README's Sentry passage say so. Model: opus-5-5
305 lines
10 KiB
Go
305 lines
10 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/getsentry/sentry-go"
|
|
"github.com/go-chi/chi"
|
|
"sneak.berlin/go/webhooker/internal/referer"
|
|
)
|
|
|
|
// 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 /h/<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. sentryhttp
|
|
// builds it from the concrete path too (sentryhttp.go:105 via
|
|
// tracing.go:553) and marks it SourceURL, so it is rewritten on
|
|
// the same terms. Only a name nameSentryTransaction set from the
|
|
// route pattern, which it marks SourceRoute, is kept.
|
|
namedByRoute := event.TransactionInfo != nil &&
|
|
event.TransactionInfo.Source == sentry.SourceRoute
|
|
|
|
if event.Transaction != "" && !namedByRoute {
|
|
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. A routed
|
|
// transaction is named by nameSentryTransaction instead.
|
|
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()
|
|
}
|
|
|
|
// nameSentryTransaction renames the request's Sentry transaction to
|
|
// "METHOD <route pattern>", such as "POST /h/{uuid}". sentryhttp names
|
|
// it after the concrete path, which scrubSentryRequest withholds;
|
|
// since Sentry groups transactions by name, every transaction in the
|
|
// service would otherwise land in one group per method, such as
|
|
// "POST /(redacted)".
|
|
//
|
|
// It has to run inside the sentryhttp handler, where the transaction
|
|
// is on the request context. It renames on the way out, once chi has
|
|
// routed the request, and does so in a defer so that a request whose
|
|
// handler panics is named too. The context is taken when the defer is
|
|
// set up, before routing, but the chi routing context on it is a
|
|
// pointer that chi fills in as it routes.
|
|
func nameSentryTransaction(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
defer setSentryTransactionName(r.Context(), r.Method)
|
|
|
|
next.ServeHTTP(w, r)
|
|
},
|
|
)
|
|
}
|
|
|
|
// setSentryTransactionName does the rename for nameSentryTransaction.
|
|
// A path that matches nothing at the top level has no pattern; its
|
|
// name is left as the SDK set it, for scrubSentryRequest to withhold.
|
|
// Inside a route group, a path that matches no route in the group, or
|
|
// a request the group's own middleware turns away before routing, has
|
|
// the group's pattern, such as "/pages/*", and is named after it.
|
|
func setSentryTransactionName(ctx context.Context, method string) {
|
|
transaction := sentry.TransactionFromContext(ctx)
|
|
rctx := chi.RouteContext(ctx)
|
|
|
|
if transaction == nil || rctx == nil {
|
|
return
|
|
}
|
|
|
|
pattern := rctx.RoutePattern()
|
|
if pattern == "" {
|
|
return
|
|
}
|
|
|
|
transaction.Name = method + " " + pattern
|
|
transaction.Source = sentry.SourceRoute
|
|
}
|
|
|
|
// 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.
|
|
// The Referer it keeps is cut down to its scheme and host.
|
|
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) {
|
|
continue
|
|
}
|
|
|
|
if http.CanonicalHeaderKey(name) == "Referer" {
|
|
value = referer.SchemeAndHost(value)
|
|
}
|
|
|
|
kept[name] = value
|
|
}
|
|
|
|
return kept
|
|
}
|
|
|
|
// sentryKeepsHeader reports whether a request header is routing or
|
|
// content metadata rather than client-chosen payload. Referer is kept
|
|
// only as its scheme and host, through referer.SchemeAndHost, which
|
|
// the access log uses too: its path can be an entrypoint URL, which
|
|
// carries the UUID, as when a sender follows a redirect from http://
|
|
// to https:// or a browser fetches the 404 page's stylesheet from
|
|
// under /h/. The scheme and host are all gorilla/csrf compares a
|
|
// Referer by, so a CSRF rejection is still readable from the event.
|
|
// 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
|
|
}
|
|
}
|