package handlers_test import ( "context" "net/http" "net/http/httptest" "net/url" "strconv" "strings" "testing" "github.com/go-chi/chi" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm/clause" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/session" ) // paramEventID is the chi URL parameter the body download // handler reads. const paramEventID = "eventID" // otherTestUserID owns webhooks the session user must not be // able to read. const otherTestUserID = "other-user-id" // seedWebhookFor inserts a webhook owned by the given user. func seedWebhookFor( t *testing.T, db *database.Database, userID string, ) *database.Webhook { t.Helper() wh := &database.Webhook{ UserID: userID, Name: "wh-" + userID, } require.NoError( t, db.DB().Omit(clause.Associations).Create(wh).Error, ) return wh } // fetchEventBody runs the real download handler as the test user // for the given source and event ids. func fetchEventBody( t *testing.T, h *handlers.Handlers, sess *session.Session, sourceID, eventID string, ) *httptest.ResponseRecorder { t.Helper() // The path is escaped and the raw id goes in the route // context, which is what chi hands a handler: the param is // already percent-decoded by the time it is read. req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/source/"+url.PathEscape(sourceID)+ "/logs/"+url.PathEscape(eventID)+"/body", nil, ) for _, c := range authenticatedCookies( t, sess, deleteTestUserID, deleteTestUsername, ) { req.AddCookie(c) } rctx := chi.NewRouteContext() rctx.URLParams.Add(paramSourceID, sourceID) rctx.URLParams.Add(paramEventID, eventID) req = req.WithContext( context.WithValue( req.Context(), chi.RouteCtxKey, rctx, ), ) w := httptest.NewRecorder() h.HandleEventBodyDownload().ServeHTTP(w, req) return w } // TestHandleEventBodyDownload_ServesOversizeBodyInFull is the // capability the render cap took away: a body far above what the // event log page will show comes back whole and byte-identical, // with the headers that keep it from being rendered. func TestHandleEventBodyDownload_ServesOversizeBodyInFull( t *testing.T, ) { t.Parallel() var ( h *handlers.Handlers sess *session.Session db *database.Database dbMgr *database.WebhookDBManager ) app := newTestApp(t, &h, &sess, &db, &dbMgr) app.RequireStart() t.Cleanup(app.RequireStop) // Larger than one read chunk as well as the render cap, so // the chunked read has to reassemble the body in order and // the tail past the last whole chunk is exercised. const sentinel = "TAIL-SENTINEL-1f4a9c" stored := strings.Repeat("A", 200*1024) + strings.Repeat(snowman, 1000) + sentinel wh := seedWebhook(t, db) evt := seedEventWithBody(t, dbMgr, wh.ID, stored) w := fetchEventBody(t, h, sess, wh.ID, evt.ID) require.Equal(t, http.StatusOK, w.Code) assert.Greater(t, len(stored), bodyCap) assert.Equal(t, stored, w.Body.String()) assert.Equal( t, strconv.Itoa(len(stored)), w.Header().Get("Content-Length"), ) } // TestHandleEventBodyDownload_HeadersAreNotRenderable pins the // response headers that stop attacker-supplied bytes executing // in the operator's own origin. They are a security control, not // presentation. func TestHandleEventBodyDownload_HeadersAreNotRenderable( t *testing.T, ) { t.Parallel() var ( h *handlers.Handlers sess *session.Session db *database.Database dbMgr *database.WebhookDBManager ) app := newTestApp(t, &h, &sess, &db, &dbMgr) app.RequireStart() t.Cleanup(app.RequireStop) wh := seedWebhook(t, db) evt := seedEventWithBody(t, dbMgr, wh.ID, `{"small":true}`) w := fetchEventBody(t, h, sess, wh.ID, evt.ID) require.Equal(t, http.StatusOK, w.Code) assert.Equal( t, "application/octet-stream", w.Header().Get("Content-Type"), ) assert.Equal( t, "nosniff", w.Header().Get("X-Content-Type-Options"), ) disposition := w.Header().Get("Content-Disposition") assert.Equal( t, `attachment; filename="webhooker-event-`+evt.ID+`.bin"`, disposition, ) } // TestHandleEventBodyDownload_ScriptBodyStaysInert proves a // stored HTML payload is handed back as an attachment of opaque // bytes rather than as anything a browser will execute. The // bytes themselves are unaltered: this route reports what was // delivered. func TestHandleEventBodyDownload_ScriptBodyStaysInert( t *testing.T, ) { t.Parallel() var ( h *handlers.Handlers sess *session.Session db *database.Database dbMgr *database.WebhookDBManager ) app := newTestApp(t, &h, &sess, &db, &dbMgr) app.RequireStart() t.Cleanup(app.RequireStop) const payload = `` wh := seedWebhook(t, db) evt := seedEventWithBody(t, dbMgr, wh.ID, payload) w := fetchEventBody(t, h, sess, wh.ID, evt.ID) require.Equal(t, http.StatusOK, w.Code) assert.Equal(t, payload, w.Body.String()) contentType := w.Header().Get("Content-Type") assert.Equal(t, "application/octet-stream", contentType) assert.NotContains(t, contentType, "html") assert.NotContains(t, contentType, "xml") assert.NotContains(t, contentType, "javascript") assert.Contains( t, w.Header().Get("Content-Disposition"), "attachment", ) assert.Equal( t, "nosniff", w.Header().Get("X-Content-Type-Options"), ) } // TestHandleEventBodyDownload_OtherUsersEvent404s is the // authorization test the definition of done asks for: an event // stored under a webhook the session user does not own is not // readable, and the miss does not distinguish itself from a // nonexistent one. func TestHandleEventBodyDownload_OtherUsersEvent404s( t *testing.T, ) { t.Parallel() var ( h *handlers.Handlers sess *session.Session db *database.Database dbMgr *database.WebhookDBManager ) app := newTestApp(t, &h, &sess, &db, &dbMgr) app.RequireStart() t.Cleanup(app.RequireStop) const theirPayload = "OTHER-USERS-PAYLOAD-8b1d" theirs := seedWebhookFor(t, db, otherTestUserID) evt := seedEventWithBody(t, dbMgr, theirs.ID, theirPayload) w := fetchEventBody(t, h, sess, theirs.ID, evt.ID) assert.Equal(t, http.StatusNotFound, w.Code) assert.NotContains(t, w.Body.String(), theirPayload) } // TestHandleEventBodyDownload_EventOfAnotherWebhook404s pins // that holding a valid event id is not enough: the event has to // belong to the webhook in the path. Both webhooks here are the // session user's and both have event databases, so the // ownership check cannot be what produces the 404. // // What does produce it is the per-webhook database file rather // than the webhook_id predicate on the query — removing that // predicate leaves this test green, because the sibling's event // is in a different file. The test is kept as the behavioural // guard the route owes; see serveEventBody for which mechanism // is load-bearing. func TestHandleEventBodyDownload_EventOfAnotherWebhook404s( t *testing.T, ) { t.Parallel() var ( h *handlers.Handlers sess *session.Session db *database.Database dbMgr *database.WebhookDBManager ) app := newTestApp(t, &h, &sess, &db, &dbMgr) app.RequireStart() t.Cleanup(app.RequireStop) const other = "BELONGS-TO-THE-OTHER-WEBHOOK-3c7e" mine := seedWebhook(t, db) seedEventWithBody(t, dbMgr, mine.ID, `{"mine":true}`) sibling := seedWebhook(t, db) evt := seedEventWithBody(t, dbMgr, sibling.ID, other) w := fetchEventBody(t, h, sess, mine.ID, evt.ID) assert.Equal(t, http.StatusNotFound, w.Code) assert.NotContains(t, w.Body.String(), other) } // TestHandleEventBodyDownload_UnknownEvent404s covers the plain // miss, including an id that is not a uuid at all and so never // reaches the query or the response header. func TestHandleEventBodyDownload_UnknownEvent404s(t *testing.T) { t.Parallel() var ( h *handlers.Handlers sess *session.Session db *database.Database dbMgr *database.WebhookDBManager ) app := newTestApp(t, &h, &sess, &db, &dbMgr) app.RequireStart() t.Cleanup(app.RequireStop) wh := seedWebhook(t, db) seedEventWithBody(t, dbMgr, wh.ID, `{"mine":true}`) for _, id := range []string{ uuid.New().String(), `../../etc/passwd`, "not-a-uuid", `x"; rm -rf /`, } { w := fetchEventBody(t, h, sess, wh.ID, id) assert.Equal( t, http.StatusNotFound, w.Code, "event id %q", id, ) assert.Empty( t, w.Header().Get("Content-Disposition"), "event id %q must not reach a header", id, ) } } // TestHandleSourceLogs_TruncationMarkerLinksToDownload proves // the page tells the reader where the rest of the body is, and // only when there is a rest to fetch. func TestHandleSourceLogs_TruncationMarkerLinksToDownload( t *testing.T, ) { t.Parallel() var ( h *handlers.Handlers sess *session.Session db *database.Database dbMgr *database.WebhookDBManager ) app := newTestApp(t, &h, &sess, &db, &dbMgr) app.RequireStart() t.Cleanup(app.RequireStop) big := seedWebhook(t, db) bigEvt := seedEventWithBody( t, dbMgr, big.ID, strings.Repeat("A", 4*bodyCap), ) page := renderSourceLogsPage(t, h, sess, big.ID) assert.Contains( t, page, "/source/"+big.ID+"/logs/"+bigEvt.ID+"/body", ) small := seedWebhook(t, db) smallEvt := seedEventWithBody( t, dbMgr, small.ID, `{"kept":"whole"}`, ) page = renderSourceLogsPage(t, h, sess, small.ID) assert.NotContains( t, page, "/source/"+small.ID+"/logs/"+smallEvt.ID+"/body", ) }