package server_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "sync" "testing" "time" "github.com/getsentry/sentry-go" sentryhttp "github.com/getsentry/sentry-go/http" "github.com/go-chi/chi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/server" ) // The four markers below are the credentials a captured event could // carry off-host, one per field of sentry.Request that the SDK fills // from the request without a SendDefaultPII guard. const ( // sentryBodyMarker is submitted as a form value. Since every // handler reads its fields with PostFormValue, the body is the // only place a password or a target URL is ever supplied. sentryBodyMarker = "QQSENTRYBODYMARKERQQ" // sentryQueryMarker rides the request line. sentryQueryMarker = "T00000000/B00000000/QQSENTRYQUERYMARKERQQ" // sentryHeaderMarker rides X-Csrf-Token, which gorilla/csrf // accepts in place of the form field. sentryHeaderMarker = "QQSENTRYHEADERMARKERQQ" // sentryReceiverUUID is the entrypoint identifier in the path of // a receiver request. It is a write capability: anyone holding // it can POST events this service accepts and its targets then // deliver, so it may not reach a third-party tracker. sentryReceiverUUID = "6d1f9c2a-3b7e-4f58-9a0d-c0ffeebadc0d" ) // sentryKeptUserAgent is a non-secret header value planted so the // assertions below cannot pass by the event carrying no headers at // all. const sentryKeptUserAgent = "webhooker-test-agent" // captureTransport records events instead of shipping them, so a test // sees exactly the payload the SDK would have put on the wire. type captureTransport struct { mu sync.Mutex events []*sentry.Event } func (c *captureTransport) Configure(sentry.ClientOptions) {} func (c *captureTransport) Flush(time.Duration) bool { return true } func (c *captureTransport) SendEvent(event *sentry.Event) { c.mu.Lock() defer c.mu.Unlock() c.events = append(c.events, event) } // sentryCase drives one request through the real sentryhttp middleware // inside a real chi router and returns the events the SDK produced. // // Routing through a chi mux is load-bearing, not decoration. chi puts // its routing context on the request context before the middleware // chain runs and fills it in as it matches, so a hand-built request // carries no route pattern at all and could not distinguish the hook // working from the hook falling back. // // This is also the only construction path on which Request.Data // appears: sentryhttp calls Scope.SetRequest, which tees r.Body into a // 10 KiB buffer, ParseForm drains the tee, and Scope.ApplyToEvent // copies the buffer into the event inside prepareEvent — before // BeforeSend runs. A hand-built sentry.NewRequest never reads the body // and so cannot regress-test any of it. type sentryCase struct { // scrub selects whether the production BeforeSend hooks are // installed, so the same path shows both what the SDK collects // and what survives. scrub bool // tracing enables the transaction dispatch, which the service // leaves off. With it on, a served request produces a // transaction event through BeforeSendTransaction. tracing bool // panics selects the error dispatch, via BeforeSend. panics bool // request builds the request to serve, given the client whose // hub it must carry. request func(*sentry.Client) *http.Request } func (c sentryCase) capture(t *testing.T) []*sentry.Event { t.Helper() transport := &captureTransport{} opts := server.SentryClientOptionsForTest( "https://public@sentry.invalid/1", "webhooker-test", ) opts.Transport = transport if !c.scrub { opts.BeforeSend = nil opts.BeforeSendTransaction = nil } if c.tracing { opts.EnableTracing = true opts.TracesSampleRate = 1.0 } client, err := sentry.NewClient(opts) require.NoError(t, err) c.router().ServeHTTP(httptest.NewRecorder(), c.request(client)) return transport.events } // router mirrors setupGlobalMiddleware's ordering over the two route // patterns these tests need: a recovering middleware first, then the // sentryhttp handler registered with Use and Repanic set, exactly as // routes.go registers it. The local recover stands in for chi's // middleware.Recoverer, which holds that slot in production; it is // here only to keep panic stacks out of the test output. func (c sentryCase) router() http.Handler { handler := func(_ http.ResponseWriter, r *http.Request) { // This call is what drains the body tee and fills the // buffer. Its success is asserted by the unscrubbed case // below, which sees the body in the event. _ = r.ParseForm() if c.panics { panic("boom") } } router := chi.NewRouter() router.Use(recoveringMiddleware) router.Use( sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle, ) router.HandleFunc("/pages/login", handler) router.HandleFunc("/webhook/{uuid}", handler) return router } func recoveringMiddleware(next http.Handler) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { defer func() { _ = recover() }() next.ServeHTTP(w, r) }, ) } // sentryLoginRequest builds the password POST most cases drive, with a // credential planted in the body, the query and a header. func sentryLoginRequest(client *sentry.Client) *http.Request { form := url.Values{} form.Set("username", "admin") form.Set("password", sentryBodyMarker) req := sentryRequest( client, "/pages/login?url=https://hooks.slack.com/services/"+ sentryQueryMarker, form.Encode(), ) req.Header.Set("X-Csrf-Token", sentryHeaderMarker) return req } // sentryReceiverRequest builds a POST to the receiver route, whose // concrete path carries the entrypoint capability. func sentryReceiverRequest(client *sentry.Client) *http.Request { return sentryRequest( client, "/webhook/"+sentryReceiverUUID, "payload=hello", ) } func sentryRequest( client *sentry.Client, target, body string, ) *http.Request { req := httptest.NewRequestWithContext( sentry.SetHubOnContext( context.Background(), sentry.NewHub(client, sentry.NewScope()), ), http.MethodPost, target, strings.NewReader(body), ) req.Header.Set( "Content-Type", "application/x-www-form-urlencoded", ) req.Header.Set("User-Agent", sentryKeptUserAgent) return req } // marshalEvent encodes an event the way the transport does. func marshalEvent(t *testing.T, event *sentry.Event) string { t.Helper() encoded, err := json.Marshal(event) require.NoError(t, err) return string(encoded) } // onlyEvent asserts a single event was captured and returns it. func onlyEvent(t *testing.T, events []*sentry.Event) *sentry.Event { t.Helper() require.Len(t, events, 1) require.NotNil(t, events[0].Request) return events[0] } // TestSentryScrub_SDKCollectsTheRequestUnscrubbed pins the premise the // hook exists for. Without it the SDK ships the whole POST body, the // raw query, the CSRF header and the concrete request path, none of // which SendDefaultPII=false suppresses. func TestSentryScrub_SDKCollectsTheRequestUnscrubbed(t *testing.T) { t.Parallel() event := onlyEvent(t, sentryCase{ panics: true, request: sentryLoginRequest, }.capture(t)) assert.Contains( t, event.Request.Data, sentryBodyMarker, "the SDK is expected to collect the POST body; if it no "+ "longer does, the scrub hook's premise changed", ) assert.Contains(t, event.Request.QueryString, sentryQueryMarker) assert.Contains( t, marshalEvent(t, event), sentryHeaderMarker, ) receiver := onlyEvent(t, sentryCase{ panics: true, request: sentryReceiverRequest, }.capture(t)) assert.Contains( t, receiver.Request.URL, sentryReceiverUUID, "the SDK is expected to build Request.URL from the "+ "concrete path; if it no longer does, the route "+ "pattern rewrite's premise changed", ) } // TestSentryScrub_RedactsTheCapturedRequest is the regression test: no // byte of any planted credential may survive into the marshalled event // that leaves the process. func TestSentryScrub_RedactsTheCapturedRequest(t *testing.T) { t.Parallel() event := onlyEvent(t, sentryCase{ scrub: true, panics: true, request: sentryLoginRequest, }.capture(t)) encoded := marshalEvent(t, event) assert.NotContains(t, encoded, sentryBodyMarker) assert.NotContains(t, encoded, sentryQueryMarker) assert.NotContains(t, encoded, sentryHeaderMarker) assert.NotContains(t, encoded, "hooks.slack.com") assert.Equal(t, "(redacted)", event.Request.Data) assert.Equal(t, "(redacted)", event.Request.QueryString) assert.Empty(t, event.Request.Cookies) assert.Empty(t, event.Request.Env) } // TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern is the // regression test for the receiver URL: the entrypoint UUID is a write // capability and may not reach the tracker, while the route it names // must still be readable there. func TestSentryScrub_ReplacesTheCapabilityPathWithTheRoutePattern( t *testing.T, ) { t.Parallel() event := onlyEvent(t, sentryCase{ scrub: true, panics: true, request: sentryReceiverRequest, }.capture(t)) assert.NotContains( t, marshalEvent(t, event), sentryReceiverUUID, ) assert.Equal( t, "http://example.com/webhook/{uuid}", event.Request.URL, ) } // TestSentryScrub_KeepsTheRoutingContext checks the hook does not cost // the debugging signal: the route, its scheme and host, the method and // the metadata headers still identify what failed. On a static route // the pattern is the path, so the URL is unchanged there. func TestSentryScrub_KeepsTheRoutingContext(t *testing.T) { t.Parallel() event := onlyEvent(t, sentryCase{ scrub: true, panics: true, request: sentryLoginRequest, }.capture(t)) assert.Equal( t, "http://example.com/pages/login", event.Request.URL, ) assert.Equal(t, http.MethodPost, event.Request.Method) assert.Equal( t, sentryKeptUserAgent, event.Request.Headers["User-Agent"], ) assert.Equal( t, "application/x-www-form-urlencoded", event.Request.Headers["Content-Type"], ) } // TestSentryScrub_RedactsTheTransactionDispatch covers the other hook. // Span.doFinish captures with a nil hint, so BeforeSendTransaction // gets one with no context and no request: the route pattern is out of // reach and both the URL and the SDK-built transaction name have to // fall back. Tracing is off in this service, so no transaction event // is produced today; the hook is a floor against that changing. func TestSentryScrub_RedactsTheTransactionDispatch(t *testing.T) { t.Parallel() events := sentryCase{ scrub: true, tracing: true, request: sentryReceiverRequest, }.capture(t) event := onlyEvent(t, events) require.Equal(t, "transaction", event.Type) assert.NotContains( t, marshalEvent(t, event), sentryReceiverUUID, ) assert.Equal( t, "http://example.com/(redacted)", event.Request.URL, ) assert.Equal(t, "POST /(redacted)", event.Transaction) } // TestSentryScrub_TransactionDispatchIsUnscrubbedWithoutTheHook pins // that dispatch's premise the same way, since it is the one the // service does not exercise today. func TestSentryScrub_TransactionDispatchIsUnscrubbedWithoutTheHook( t *testing.T, ) { t.Parallel() event := onlyEvent(t, sentryCase{ tracing: true, request: sentryReceiverRequest, }.capture(t)) require.Equal(t, "transaction", event.Type) assert.Contains(t, event.Request.URL, sentryReceiverUUID) assert.Contains(t, event.Transaction, sentryReceiverUUID) } // TestSentryScrub_FallsBackWithoutARoutePattern covers every way the // pattern can be missing. None of them may fall back to the concrete // path, and all of them keep the scheme, which is the CSRF TLS // decision. func TestSentryScrub_FallsBackWithoutARoutePattern(t *testing.T) { t.Parallel() concrete := "https://example.com/webhook/" + sentryReceiverUUID // A request with no chi routing context on it at all, which is // what an event captured outside the router would carry. unrouted := httptest.NewRequestWithContext( context.Background(), http.MethodPost, concrete, nil, ) for name, hint := range map[string]*sentry.EventHint{ "no hint": nil, "no context": {}, "no request": {Context: context.Background()}, "unrouted request": { Context: context.WithValue( context.Background(), sentry.RequestContextKey, unrouted, ), }, } { t.Run(name, func(t *testing.T) { t.Parallel() event := sentry.NewEvent() event.Request = &sentry.Request{URL: concrete} event.Transaction = "POST /webhook/" + sentryReceiverUUID scrubbed := server.ScrubSentryRequestForTest( event, hint, ) require.NotNil(t, scrubbed) assert.Equal( t, "https://example.com/(redacted)", scrubbed.Request.URL, ) assert.Equal( t, "POST /(redacted)", scrubbed.Transaction, ) assert.NotContains( t, marshalEvent(t, scrubbed), sentryReceiverUUID, ) }) } } // TestSentryScrub_WithholdsUnparseableValues covers the shapes the // rewrite cannot take apart. Withholding them whole is the safe // answer, since nothing can be said about which part is a path. func TestSentryScrub_WithholdsUnparseableValues(t *testing.T) { t.Parallel() event := sentry.NewEvent() event.Request = &sentry.Request{ URL: "/webhook/" + sentryReceiverUUID, } event.Transaction = "/webhook/" + sentryReceiverUUID scrubbed := server.ScrubSentryRequestForTest(event, nil) require.NotNil(t, scrubbed) assert.Equal(t, "(redacted)", scrubbed.Request.URL) assert.Equal(t, "(redacted)", scrubbed.Transaction) } // TestSentryScrub_ToleratesEventsWithoutARequest covers the events the // hook sees outside an HTTP handler, where no request is attached. func TestSentryScrub_ToleratesEventsWithoutARequest(t *testing.T) { t.Parallel() scrubbed := server.ScrubSentryRequestForTest( sentry.NewEvent(), nil, ) require.NotNil(t, scrubbed) assert.Nil(t, scrubbed.Request) assert.Empty(t, scrubbed.Transaction) assert.Nil(t, server.ScrubSentryRequestForTest(nil, nil)) }