package server_test import ( "net/http" "net/url" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // maxResubmits bounds the requests the tests below send to the // resubmit route. The route's rate limit belongs to the middleware; // this only has to sit well above it, so that a route without the // limiter fails its test instead of looping. const maxResubmits = 100 // resubmitPath is the resubmit route for one stored event. func resubmitPath(webhookID, eventID string) string { return "/hook/" + webhookID + "/events/" + eventID + "/resubmit" } // csrfForm is a resubmit form carrying the given CSRF token. func csrfForm(token string) url.Values { form := url.Values{} form.Set("csrf_token", token) return form } // TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit pins // RequireAuth on the resubmit route. The handler also turns away a // request without a session, with the same redirect, so a refusal // alone would pass without RequireAuth. What RequireAuth adds is that // it refuses such a request before the route's rate limit, so a // signed-out client cannot spend the budget a signed-in user // resubmits from. Each request carries a CSRF token valid for its own // cookie, so CSRF lets it through to RequireAuth. func TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit( t *testing.T, ) { t.Parallel() env := newTestEnv(t) userID, _ := env.seedUser(t, "resubmitter", "somepassword") wh := env.seedWebhook(t, userID) evt := env.seedEvent(t, wh.ID, `{"resubmit":"me"}`) path := resubmitPath(wh.ID, evt.ID) logsPath := "/hook/" + wh.ID + "/events" token, signedOut := env.csrfFrom(t, "/pages/login", nil) for i := range maxResubmits { w := env.post(path, csrfForm(token), signedOut) require.Equal(t, http.StatusSeeOther, w.Code, "request %d", i) require.Equal( t, "/pages/login", w.Header().Get("Location"), "request %d", i, ) } require.Equal( t, int64(1), env.countEvents(t, wh.ID), "a signed-out request must store nothing", ) token, cookies := env.csrfFrom( t, logsPath, env.authCookies(t, userID, "resubmitter"), ) env.requireNotice( t, env.post(path, csrfForm(token), cookies), logsPath, "resubmit-no-targets", "this source has no active targets", cookies, ) } // TestEventResubmit_RefusedWithoutAValidCSRFToken pins CSRF on the // resubmit route: a signed-in user's POST is refused with 403, and // stores nothing, unless it carries the token issued to that user's // own browser. func TestEventResubmit_RefusedWithoutAValidCSRFToken(t *testing.T) { t.Parallel() env := newTestEnv(t) userID, _ := env.seedUser(t, "resubmitter", "somepassword") wh := env.seedWebhook(t, userID) evt := env.seedEvent(t, wh.ID, `{"resubmit":"me"}`) path := resubmitPath(wh.ID, evt.ID) logsPath := "/hook/" + wh.ID + "/events" token, cookies := env.csrfFrom( t, logsPath, env.authCookies(t, userID, "resubmitter"), ) otherBrowsers, _ := env.csrfFrom(t, "/pages/login", nil) for name, form := range map[string]url.Values{ "no token": {}, "a malformed token": csrfForm("not-a-token"), "another browser's token": csrfForm(otherBrowsers), } { assert.Equal( t, http.StatusForbidden, env.post(path, form, cookies).Code, name, ) } assert.Equal( t, int64(1), env.countEvents(t, wh.ID), "a refused request must store nothing", ) // The same request with the user's own token goes through, so the // refusals above were the token's doing. env.requireNotice( t, env.post(path, csrfForm(token), cookies), logsPath, "resubmit-no-targets", "this source has no active targets", cookies, ) } // TestEventResubmit_AnotherWebhooksEvent404s pins, on the route as // registered, that a signed-in user gets 404, and nothing is stored, // for an event of a webhook another user owns, which the handler's // ownership check refuses, and for another webhook's event posted // under a webhook the user does own, which the event lookup refuses. // The user's own event, posted the same way, is accepted, so the // second 404 comes from the lookup and not from a route that never // passed the event ID to the handler. func TestEventResubmit_AnotherWebhooksEvent404s(t *testing.T) { t.Parallel() env := newTestEnv(t) ownerID, _ := env.seedUser(t, "owner", "somepassword") owners := env.seedWebhook(t, ownerID) ownersEvent := env.seedEvent(t, owners.ID, `{"owner":"only"}`) intruderID, _ := env.seedUser(t, "intruder", "somepassword") intruders := env.seedWebhook(t, intruderID) intrudersEvent := env.seedEvent( t, intruders.ID, `{"intruder":"own"}`, ) intrudersLogs := "/hook/" + intruders.ID + "/events" token, cookies := env.csrfFrom( t, intrudersLogs, env.authCookies(t, intruderID, "intruder"), ) for name, path := range map[string]string{ "another user's webhook": resubmitPath( owners.ID, ownersEvent.ID, ), "another webhook's event": resubmitPath( intruders.ID, ownersEvent.ID, ), } { w := env.post(path, csrfForm(token), cookies) assert.Equal(t, http.StatusNotFound, w.Code, name) } assert.Equal(t, int64(1), env.countEvents(t, owners.ID)) assert.Equal(t, int64(1), env.countEvents(t, intruders.ID)) env.requireNotice( t, env.post( resubmitPath(intruders.ID, intrudersEvent.ID), csrfForm(token), cookies, ), intrudersLogs, "resubmit-no-targets", "this source has no active targets", cookies, ) } // TestEventResubmit_RateLimited pins the rate limit on the resubmit // route: a signed-in user's resubmits are accepted until the budget // is spent, and then refused with 429. func TestEventResubmit_RateLimited(t *testing.T) { t.Parallel() env := newTestEnv(t) userID, _ := env.seedUser(t, "resubmitter", "somepassword") wh := env.seedWebhook(t, userID) evt := env.seedEvent(t, wh.ID, `{"resubmit":"me"}`) path := resubmitPath(wh.ID, evt.ID) token, cookies := env.csrfFrom( t, "/hook/"+wh.ID+"/events", env.authCookies(t, userID, "resubmitter"), ) limited := false for range maxResubmits { code := env.post(path, csrfForm(token), cookies).Code if code == http.StatusTooManyRequests { limited = true break } require.Equal( t, http.StatusSeeOther, code, "a resubmit within the budget must be accepted", ) } assert.True( t, limited, "repeated resubmits must eventually be refused", ) }