check / check (push) Waiting to run
sentryhttp names each transaction after the concrete path, which the BeforeSendTransaction hook has to withhold, so with tracing on the transactions would fall into one Sentry group per method, such as POST /(redacted). A middleware installed beside every sentryhttp handler now renames the transaction to the method and chi route pattern once the request is routed, and sets its source to route. The hook keeps a route-sourced name and withholds every other name as before. A path that matches nothing at the top level keeps the withheld name; one that matches no route inside a route group, or that the group's middleware turns away, is named after the group's pattern, such as GET /pages/*. Tracing stays off. Model: opus-5-5
570 lines
16 KiB
Go
570 lines
16 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/config"
|
|
"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. It leaves out nameSentryTransaction,
|
|
// which routes.go installs inside the sentryhttp handler, so a
|
|
// transaction captured here keeps the SDK's name and shows what the
|
|
// hook does with a transaction that missed the rename. 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("/h/{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, "/h/"+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/h/{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. This router has no nameSentryTransaction, so this is the
|
|
// floor under a transaction that missed the rename;
|
|
// TestSentryTransaction_NamedByRoute covers the routes as they ship.
|
|
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)
|
|
}
|
|
|
|
// TestSentryTransaction_NamedByRoute serves requests through the real
|
|
// route tree with tracing on, which the service leaves off. A routed
|
|
// request's transaction is named after its route pattern, so Sentry
|
|
// can group transactions by route, and the entrypoint UUID in the
|
|
// receiver path appears nowhere in the event. A path that matches
|
|
// nothing at the top level has no pattern, and its name is still
|
|
// withheld; one that matches no route inside a route group is named
|
|
// after the group's pattern.
|
|
func TestSentryTransaction_NamedByRoute(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// newTestEnv leaves the receiver rate limit at zero, which
|
|
// refuses every request before it reaches the receiver.
|
|
env := newTestEnvWithConfig(t, &config.Config{
|
|
DataDir: t.TempDir(),
|
|
Environment: config.EnvironmentDev,
|
|
ReceiverRateLimit: 10,
|
|
})
|
|
|
|
router := server.NewRouterWithSentryForTest(
|
|
t, env.log, env.cfg, env.mw, env.hnd,
|
|
)
|
|
|
|
for _, tc := range []struct {
|
|
method, path, want string
|
|
}{
|
|
{
|
|
http.MethodPost,
|
|
"/h/" + sentryReceiverUUID,
|
|
"POST /h/{uuid}",
|
|
},
|
|
// An admin page route group, which installs its own
|
|
// sentryhttp handler.
|
|
{http.MethodGet, "/pages/login", "GET /pages/login"},
|
|
{http.MethodGet, "/pages/unrouted", "GET /pages/*"},
|
|
{
|
|
http.MethodPost,
|
|
"/h/" + sentryReceiverUUID + "/unrouted",
|
|
"POST /(redacted)",
|
|
},
|
|
} {
|
|
t.Run(tc.want, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := &captureTransport{}
|
|
|
|
opts := server.SentryClientOptionsForTest(
|
|
"https://public@sentry.invalid/1",
|
|
"webhooker-test",
|
|
)
|
|
opts.Transport = transport
|
|
opts.EnableTracing = true
|
|
opts.TracesSampleRate = 1.0
|
|
|
|
client, err := sentry.NewClient(opts)
|
|
require.NoError(t, err)
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
sentry.SetHubOnContext(
|
|
context.Background(),
|
|
sentry.NewHub(client, sentry.NewScope()),
|
|
),
|
|
tc.method, tc.path, nil,
|
|
)
|
|
router.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
event := onlyEvent(t, transport.events)
|
|
require.Equal(t, "transaction", event.Type)
|
|
|
|
assert.Equal(t, tc.want, event.Transaction)
|
|
assert.NotContains(
|
|
t, marshalEvent(t, event), sentryReceiverUUID,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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/h/" + 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 /h/" +
|
|
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: "/h/" + sentryReceiverUUID,
|
|
}
|
|
event.Transaction = "/h/" + 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))
|
|
}
|