check / check (push) Successful in 4m23s
The webhook page and everything under it move from /source/ID to /hook/ID, the list and new-webhook form to /hooks and /hooks/new, and the event log from .../logs to /hook/ID/events, body download included. Entrypoint URLs move from /webhook/UUID to /h/UUID, and the webhook page shows only that form. The old paths are gone. Links, redirects, form actions, tests, comments and the README follow. Go identifiers and template file names are unchanged. A new route test posts to the entrypoint URL the webhook page shows and checks that the receiver rate limit applies to it. Model: opus-5-5
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 /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, 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
|
|
}
|
|
}
|