Compare commits
1
Commits
next
...
6688aa364b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6688aa364b |
@@ -145,8 +145,9 @@ func (h *Handlers) resubmitEvent(
|
|||||||
// per-webhook database files — a sibling webhook's event is not in the
|
// per-webhook database files — a sibling webhook's event is not in the
|
||||||
// database being queried at all — and is there so the scoping survives
|
// database being queried at all — and is there so the scoping survives
|
||||||
// any future change that puts more than one webhook's events in one
|
// any future change that puts more than one webhook's events in one
|
||||||
// file. Going through Model applies GORM's soft-delete scope, which is
|
// file. A reaped event is not found because the retention reaper
|
||||||
// what stops a reaped event being resubmitted.
|
// deletes its row outright rather than marking it deleted; see
|
||||||
|
// deleteEvents in internal/database/retention.go.
|
||||||
func loadResubmitSource(
|
func loadResubmitSource(
|
||||||
webhookDB *gorm.DB,
|
webhookDB *gorm.DB,
|
||||||
webhookID, eventID string,
|
webhookID, eventID string,
|
||||||
|
|||||||
@@ -272,10 +272,12 @@ func requestEventSource(
|
|||||||
|
|
||||||
// createAndFanOut writes the event and one pending delivery per target,
|
// createAndFanOut writes the event and one pending delivery per target,
|
||||||
// and adds them to the webhook's running totals, in a single
|
// and adds them to the webhook's running totals, in a single
|
||||||
// transaction, then hands the tasks to the delivery engine. It is the
|
// transaction, then hands the tasks to the delivery engine. Every
|
||||||
// only path by which an event and its deliveries are created, so a
|
// event is created here, received or resubmitted, so a resubmitted
|
||||||
// resubmitted event is retried, SSRF-guarded and circuit-broken
|
// event is retried, SSRF-guarded and circuit-broken exactly as a
|
||||||
// exactly as a received one is.
|
// received one is. Per-delivery replay is the one other path that
|
||||||
|
// creates a delivery: it adds one to an existing event without
|
||||||
|
// coming through here.
|
||||||
//
|
//
|
||||||
// The tasks are returned as well as queued, so a caller can report how
|
// The tasks are returned as well as queued, so a caller can report how
|
||||||
// many targets the event went to.
|
// many targets the event went to.
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
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 the ownership check
|
||||||
|
// the resubmit handler makes, on the route as registered: a signed-in
|
||||||
|
// user gets 404, and nothing is stored, for an event of a webhook
|
||||||
|
// another user owns, and for another webhook's event posted under a
|
||||||
|
// webhook the user does own.
|
||||||
|
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)
|
||||||
|
// An event of its own gives the intruder's webhook its database,
|
||||||
|
// so the second request below gets as far as the event lookup.
|
||||||
|
env.seedEvent(t, intruders.ID, `{"intruder":"own"}`)
|
||||||
|
|
||||||
|
token, cookies := env.csrfFrom(
|
||||||
|
t, "/hook/"+intruders.ID+"/events",
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -444,6 +444,23 @@ func (e *testEnv) countDeliveries(
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countEvents reports how many events a webhook's database holds.
|
||||||
|
func (e *testEnv) countEvents(t *testing.T, webhookID string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhookDB, err := e.dbMgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
webhookDB.Model(&database.Event{}).Count(&count).Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
// storedHash reads the current password hash for a username.
|
// storedHash reads the current password hash for a username.
|
||||||
func (e *testEnv) storedHash(t *testing.T, username string) string {
|
func (e *testEnv) storedHash(t *testing.T, username string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
Reference in New Issue
Block a user