487 lines
14 KiB
Go
487 lines
14 KiB
Go
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 the one ordering these tests depend on, over the two
|
|
// route patterns they need: a recovering middleware outside, then the
|
|
// sentryhttp handler registered with Use and Repanic set, exactly as
|
|
// routes.go orders the two. The bare recover stands in for
|
|
// Middleware.Recoverer, which holds that outer slot in production; it
|
|
// is here only to keep panic stacks out of the test output. That the
|
|
// production one really does catch what sentryhttp re-raises is
|
|
// pinned separately, by TestSentryStillSeesAPanic.
|
|
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))
|
|
}
|