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/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/server" ) // The three 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" ) // 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) } // captureThroughSentryHTTP panics inside a form handler wrapped in the // real sentryhttp middleware and returns the event the SDK produced. // // This is 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. // // scrub selects whether the production BeforeSend hooks are installed, // so the same path shows both what the SDK collects and what survives. func captureThroughSentryHTTP(t *testing.T, scrub bool) *sentry.Event { t.Helper() transport := &captureTransport{} opts := server.SentryClientOptionsForTest( "https://public@sentry.invalid/1", "webhooker-test", ) opts.Transport = transport if !scrub { opts.BeforeSend = nil opts.BeforeSendTransaction = nil } client, err := sentry.NewClient(opts) require.NoError(t, err) handler := sentryhttp.New(sentryhttp.Options{}).Handle( http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { // This call is what drains the tee and fills the // buffer. Its success is asserted by the unscrubbed // case below, which sees the body in the event. _ = r.ParseForm() panic("boom") }), ) handler.ServeHTTP( httptest.NewRecorder(), sentryLoginRequest(client), ) require.Len(t, transport.events, 1) return transport.events[0] } // sentryLoginRequest builds the password POST the capture above drives, // 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 := httptest.NewRequestWithContext( sentry.SetHubOnContext( context.Background(), sentry.NewHub(client, sentry.NewScope()), ), http.MethodPost, "/pages/login?url=https://hooks.slack.com/services/"+ sentryQueryMarker, strings.NewReader(form.Encode()), ) req.Header.Set( "Content-Type", "application/x-www-form-urlencoded", ) req.Header.Set("X-Csrf-Token", sentryHeaderMarker) 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) } // TestSentryScrub_SDKCollectsTheRequestUnscrubbed pins the premise the // hook exists for. Without it the SDK ships the whole POST body, the // raw query and the CSRF header, none of which SendDefaultPII=false // suppresses. func TestSentryScrub_SDKCollectsTheRequestUnscrubbed(t *testing.T) { t.Parallel() event := captureThroughSentryHTTP(t, false) require.NotNil(t, event.Request) 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, ) } // 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 := captureThroughSentryHTTP(t, true) require.NotNil(t, event.Request) 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_KeepsTheRoutingContext checks the hook does not cost // the debugging signal: the route, the method and the metadata headers // still identify what failed. func TestSentryScrub_KeepsTheRoutingContext(t *testing.T) { t.Parallel() event := captureThroughSentryHTTP(t, true) require.NotNil(t, event.Request) assert.Contains(t, event.Request.URL, "/pages/login") 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_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.Nil(t, server.ScrubSentryRequestForTest(nil, nil)) }