Compare commits
2 Commits
b1cf0de216
...
6f37d05ab6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f37d05ab6 | ||
| c3b6623be1 |
@@ -1001,7 +1001,14 @@ Every limiter here — receiver, login, and password change — identifies
|
||||
the client the same way, through one shared key function: the
|
||||
connection's own address, unless the peer is listed in
|
||||
`TRUSTED_PROXIES`, in which case the forwarded client address is used
|
||||
instead. See [Trusted proxies](#trusted-proxies). Deployed without that
|
||||
instead. That address becomes a bucket by family: IPv4 keys on the full
|
||||
address, IPv6 on its `/64` prefix. A routed `/64` is the normal
|
||||
residential and mobile IPv6 allocation, so keying IPv6 per address would
|
||||
let one subscriber rotate source addresses and mint a fresh bucket per
|
||||
request, evading these limits at the network layer without spoofing
|
||||
anything; the cost is that distinct clients inside one `/64` share a
|
||||
bucket. IPv4-mapped addresses (`::ffff:1.2.3.4`) key as the IPv4 address
|
||||
they carry. See [Trusted proxies](#trusted-proxies). Deployed without that
|
||||
variable set, a client behind a reverse proxy shares one bucket with
|
||||
every other client behind the same proxy. Set `TRUSTED_PROXIES` to the
|
||||
proxy's address to get per-client limits back. What the shared bucket
|
||||
|
||||
192
internal/handlers/event_body.go
Normal file
192
internal/handlers/event_body.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// eventBodyQuery reads one event's stored body as bytes. The cast
|
||||
// to blob is what makes the driver hand back the stored bytes
|
||||
// rather than a string conversion, so Content-Length taken from
|
||||
// the result matches what goes on the wire. The soft-delete
|
||||
// predicate is spelled out because Raw bypasses GORM's default
|
||||
// scope, and it is what stops a reaped event still being
|
||||
// downloadable.
|
||||
const eventBodyQuery = "SELECT cast(body as blob) " +
|
||||
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
||||
|
||||
// HandleEventBodyDownload serves one event's stored body in
|
||||
// full, which the event log page cannot: it caps each rendered
|
||||
// body at maxRenderedBodyBytes.
|
||||
//
|
||||
// The bytes are attacker-supplied — anyone who can reach the
|
||||
// public receiver chooses them — and this route hands them back
|
||||
// inside the operator's own authenticated origin, so the
|
||||
// response is deliberately not renderable. Content-Disposition
|
||||
// makes the browser download rather than display it, and the
|
||||
// octet-stream type plus nosniff stop it being interpreted as
|
||||
// HTML or script. Without those a stored payload would execute
|
||||
// as the logged-in operator. The application's CSP does not
|
||||
// help here: script-src allows 'unsafe-inline' from 'self', so
|
||||
// a document served from this origin could run its own inline
|
||||
// script.
|
||||
func (h *Handlers) HandleEventBodyDownload() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
webhook, ok := h.ownedWebhook(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Parsing the id before use serves two purposes: a
|
||||
// malformed id can never reach the SQL or the response
|
||||
// header, and the canonical form below is drawn from
|
||||
// uuid's own fixed alphabet rather than from the
|
||||
// request, so the Content-Disposition value cannot be
|
||||
// steered by a client.
|
||||
eventID, err := uuid.Parse(chi.URLParam(r, "eventID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.serveEventBody(w, r, webhook, eventID.String())
|
||||
}
|
||||
}
|
||||
|
||||
// serveEventBody writes the named event's stored body to w.
|
||||
//
|
||||
// The event must belong to webhook, which is what keeps this
|
||||
// route from reading any event in the system by id alone. Two
|
||||
// things enforce that and they are not equally strong. The
|
||||
// operative one is that events live in a per-webhook SQLite
|
||||
// file, so a sibling webhook's event is not in the database
|
||||
// being queried at all. The webhook_id predicate on the query
|
||||
// below is the second guard, and it is currently redundant
|
||||
// against that isolation; it is there so the scoping survives
|
||||
// any future change that puts more than one webhook's events in
|
||||
// one file.
|
||||
//
|
||||
// The body is read in one query and held whole in memory while
|
||||
// it is written. That is the bound: one body per concurrent
|
||||
// download, and a body is capped at 1 MB when it is ingested,
|
||||
// so a download cannot cost more than that. There is no
|
||||
// cheaper bound available — database/sql exposes no incremental
|
||||
// handle on a SQLite BLOB, and reading byte ranges with substr
|
||||
// does not avoid the cost either, because SQLite materialises
|
||||
// the whole column value to evaluate each substr call. Range
|
||||
// reads only pay for that materialisation once per range.
|
||||
//
|
||||
// One consequence is worth keeping in view: the read finishes
|
||||
// before the client is written to, so no read lock is held for
|
||||
// the length of a slow download. These per-webhook databases
|
||||
// run in SQLite's default journal mode rather than WAL, so a
|
||||
// lock held that long would block the receiver from recording
|
||||
// new events.
|
||||
func (h *Handlers) serveEventBody(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
webhook database.Webhook,
|
||||
eventID string,
|
||||
) {
|
||||
if !h.dbMgr.DBExists(webhook.ID) {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to get webhook database", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, found, err := eventBody(webhookDB, webhook.ID, eventID)
|
||||
if err != nil {
|
||||
h.serverError(w, "failed to read event body", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// A miss is a 404 whether the event belongs to another
|
||||
// webhook or does not exist at all, so the response does
|
||||
// not report which. Reading the body before any header is
|
||||
// written is also what keeps an event reaped mid-request
|
||||
// from producing a torn response: either the read finds the
|
||||
// row and the whole body is served, or it does not and the
|
||||
// response is a clean 404.
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setEventBodyHeaders(w, eventID, int64(len(body)))
|
||||
|
||||
_, err = w.Write(body)
|
||||
if err != nil {
|
||||
// The status and Content-Length are already committed,
|
||||
// so the client sees a short download. There is no way
|
||||
// to report a 500 from here; the log is the record.
|
||||
h.log.Error(
|
||||
"failed to write event body",
|
||||
"webhook_id", webhook.ID,
|
||||
"event_id", eventID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// eventBody returns an event's stored body and whether the event
|
||||
// exists within the webhook.
|
||||
func eventBody(
|
||||
webhookDB *gorm.DB,
|
||||
webhookID, eventID string,
|
||||
) ([]byte, bool, error) {
|
||||
var body []byte
|
||||
|
||||
err := webhookDB.Raw(
|
||||
eventBodyQuery, eventID, webhookID,
|
||||
).Row().Scan(&body)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return body, true, nil
|
||||
}
|
||||
|
||||
// setEventBodyHeaders applies the response headers that make
|
||||
// this route safe to hand attacker-supplied bytes through. See
|
||||
// HandleEventBodyDownload for why they are a security control
|
||||
// and not a formatting choice.
|
||||
//
|
||||
// nosniff is also set by the global SecurityHeaders middleware.
|
||||
// It is repeated here so the guarantee belongs to the route
|
||||
// that needs it rather than to a middleware someone could
|
||||
// reorder or scope away.
|
||||
func setEventBodyHeaders(
|
||||
w http.ResponseWriter,
|
||||
eventID string,
|
||||
size int64,
|
||||
) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="webhooker-event-`+eventID+`.bin"`,
|
||||
)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
}
|
||||
506
internal/handlers/event_body_test.go
Normal file
506
internal/handlers/event_body_test.go
Normal file
@@ -0,0 +1,506 @@
|
||||
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)
|
||||
|
||||
// Far above the render cap, with multibyte runes and a
|
||||
// distinctive tail, so a body that the log page can only
|
||||
// show a slice of comes back whole and in order.
|
||||
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_BodiesRoundTripByteIdentical
|
||||
// covers the sizes and byte values a stored body can actually
|
||||
// take: empty, one byte, either side of the render cap, and
|
||||
// bytes that are not text at all. Content-Length has to equal
|
||||
// the bytes written in every case, since it is derived from the
|
||||
// same read that produces them.
|
||||
func TestHandleEventBodyDownload_BodiesRoundTripByteIdentical(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
// A NUL, invalid UTF-8 and a multibyte rune, so nothing on
|
||||
// the path can be treating the body as text.
|
||||
binary := "\x00\x01\xff\xfe" + snowman + "\x00tail"
|
||||
|
||||
cases := map[string]string{
|
||||
"empty": "",
|
||||
"single byte": "x",
|
||||
"one below cap": strings.Repeat("b", bodyCap-1),
|
||||
"exactly cap": strings.Repeat("c", bodyCap),
|
||||
"one above cap": strings.Repeat("d", bodyCap+1),
|
||||
"binary": binary,
|
||||
}
|
||||
|
||||
for name, stored := range cases {
|
||||
t.Run(name, func(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, stored)
|
||||
|
||||
w := fetchEventBody(t, h, sess, wh.ID, evt.ID)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, stored, w.Body.String())
|
||||
assert.Equal(
|
||||
t, strconv.Itoa(len(stored)),
|
||||
w.Header().Get("Content-Length"),
|
||||
)
|
||||
assert.Equal(
|
||||
t, len(stored), w.Body.Len(),
|
||||
"Content-Length must equal bytes written",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = `<html><script>alert(document.cookie)` +
|
||||
`</script></html>`
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleEventBodyDownload_ReapedEvent404s pins what happens
|
||||
// when the retention reaper takes an event out from under this
|
||||
// route. The body is read in one query before any header is
|
||||
// written, so a reaped event cannot produce a partial download:
|
||||
// it is a clean 404 with no Content-Length and no
|
||||
// Content-Disposition. Both removals the codebase performs are
|
||||
// covered — the reaper hard-deletes, and a soft-deleted row is
|
||||
// excluded by the query's own deleted_at predicate rather than
|
||||
// by GORM's default scope, which Raw bypasses.
|
||||
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, hard := range map[string]bool{
|
||||
"soft deleted": false,
|
||||
"hard deleted": true,
|
||||
} {
|
||||
t.Run(name, func(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 = "REAPED-PAYLOAD-4d2a"
|
||||
|
||||
wh := seedWebhook(t, db)
|
||||
evt := seedEventWithBody(t, dbMgr, wh.ID, payload)
|
||||
|
||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
del := webhookDB
|
||||
if hard {
|
||||
del = del.Unscoped()
|
||||
}
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
del.Delete(&database.Event{}, "id = ?", evt.ID).
|
||||
Error,
|
||||
)
|
||||
|
||||
w := fetchEventBody(t, h, sess, wh.ID, evt.ID)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.NotContains(t, w.Body.String(), payload)
|
||||
assert.Empty(t, w.Header().Get("Content-Length"))
|
||||
assert.Empty(
|
||||
t, w.Header().Get("Content-Disposition"),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
)
|
||||
}
|
||||
@@ -25,13 +25,14 @@ const bodyCap = handlers.MaxRenderedBodyBytesForTest
|
||||
const snowman = "☃"
|
||||
|
||||
// seedEventWithBody records one event with the given body in the
|
||||
// webhook's own database.
|
||||
// webhook's own database and returns it, so a caller that needs
|
||||
// the generated event id can have it.
|
||||
func seedEventWithBody(
|
||||
t *testing.T,
|
||||
dbMgr *database.WebhookDBManager,
|
||||
webhookID string,
|
||||
body string,
|
||||
) {
|
||||
) *database.Event {
|
||||
t.Helper()
|
||||
|
||||
webhookDB, err := dbMgr.GetDB(webhookID)
|
||||
@@ -47,6 +48,8 @@ func seedEventWithBody(
|
||||
require.NoError(t, webhookDB.Omit(
|
||||
clause.Associations,
|
||||
).Create(event).Error)
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// seedAndProject stores one body and returns the projection the
|
||||
|
||||
@@ -713,29 +713,55 @@ func (h *Handlers) evictArchiveWriterIfUnused(webhookID string) {
|
||||
h.evictArchiveWriter(webhookID)
|
||||
}
|
||||
|
||||
// ownedWebhook resolves the request's sourceID parameter to a
|
||||
// webhook the session's user owns.
|
||||
//
|
||||
// Ownership and existence are decided by one query, so a
|
||||
// webhook belonging to another user is indistinguishable from
|
||||
// one that does not exist: both are a 404, and neither confirms
|
||||
// the id. Callers that reach further into a webhook's data —
|
||||
// the event log page and the event body download — share this
|
||||
// one check rather than restating it, so the download cannot
|
||||
// come to authorize differently from the page that links to it.
|
||||
//
|
||||
// It reports false once it has written the response, which is a
|
||||
// redirect to the login page for an unauthenticated request and
|
||||
// a 404 otherwise. The caller returns without writing more.
|
||||
func (h *Handlers) ownedWebhook(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) (database.Webhook, bool) {
|
||||
var webhook database.Webhook
|
||||
|
||||
userID, ok := h.getUserID(r)
|
||||
if !ok {
|
||||
http.Redirect(
|
||||
w, r, "/pages/login", http.StatusSeeOther,
|
||||
)
|
||||
|
||||
return database.Webhook{}, false
|
||||
}
|
||||
|
||||
sourceID := chi.URLParam(r, "sourceID")
|
||||
|
||||
err := h.db.DB().Where(
|
||||
"id = ? AND user_id = ?", sourceID, userID,
|
||||
).First(&webhook).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return database.Webhook{}, false
|
||||
}
|
||||
|
||||
return webhook, true
|
||||
}
|
||||
|
||||
// HandleSourceLogs shows the request/response logs for a
|
||||
// webhook.
|
||||
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.getUserID(r)
|
||||
webhook, ok := h.ownedWebhook(w, r)
|
||||
if !ok {
|
||||
http.Redirect(
|
||||
w, r, "/pages/login", http.StatusSeeOther,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sourceID := chi.URLParam(r, "sourceID")
|
||||
|
||||
var webhook database.Webhook
|
||||
|
||||
err := h.db.DB().Where(
|
||||
"id = ? AND user_id = ?", sourceID, userID,
|
||||
).First(&webhook).Error
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,12 @@ const (
|
||||
// bound every request pays a walk proportional to whatever the
|
||||
// client sent.
|
||||
maxForwardedHops = 64
|
||||
|
||||
// ipv6BucketBits is the prefix length IPv6 clients are bucketed
|
||||
// on. A routed /64 is the normal residential and mobile
|
||||
// allocation, so it is the unit an attacker gets addresses in
|
||||
// and therefore the unit worth limiting.
|
||||
ipv6BucketBits = 64
|
||||
)
|
||||
|
||||
// normalizeAddr strips the IPv4-in-IPv6 wrapper and any zone from
|
||||
@@ -56,6 +62,40 @@ func normalizeAddr(addr netip.Addr) netip.Addr {
|
||||
return addr.Unmap().WithZone("")
|
||||
}
|
||||
|
||||
// bucketKey is the rate-limit bucket identity of a client address.
|
||||
// IPv4 keys on the full address; IPv6 keys on its /64 prefix,
|
||||
// because keying IPv6 per /128 lets one ordinary subscriber rotate
|
||||
// source addresses inside its own routed /64 and mint a fresh bucket
|
||||
// per request — evading every limiter here at the network layer,
|
||||
// with no spoofing and nothing to detect.
|
||||
//
|
||||
// An IPv4-mapped address (::ffff:1.2.3.4) is keyed as the IPv4
|
||||
// address it carries, never masked to a /64: mapped form all shares
|
||||
// the ::ffff:0:0/96 prefix, so masking would collapse every IPv4
|
||||
// client reaching a proxy that emits it into one bucket. Callers
|
||||
// pass addresses through normalizeAddr, which already unmaps; the
|
||||
// unmap here keeps the property true of the key function itself.
|
||||
//
|
||||
// The two families cannot collide: an IPv4 key is a bare dotted
|
||||
// quad, and an IPv6 key always carries a "/64" suffix.
|
||||
func bucketKey(addr netip.Addr) string {
|
||||
addr = addr.Unmap()
|
||||
|
||||
if addr.Is4() {
|
||||
return addr.String()
|
||||
}
|
||||
|
||||
// Prefix errors only on a negative bit count, on over 32 bits
|
||||
// for an IPv4 address, or on over 128 for IPv6. The count here
|
||||
// is the constant 64 and the IPv4 case returned above, so the
|
||||
// error is unreachable. (The zero Addr does not error either: it
|
||||
// yields the zero Prefix. Neither call site can produce one,
|
||||
// since both parse the address first.)
|
||||
prefix, _ := addr.Prefix(ipv6BucketBits)
|
||||
|
||||
return prefix.String()
|
||||
}
|
||||
|
||||
// isTrustedProxy reports whether addr belongs to a network the
|
||||
// operator listed in TRUSTED_PROXIES. The list is empty by default,
|
||||
// so by default nothing is trusted.
|
||||
@@ -143,6 +183,9 @@ func (m *Middleware) forwardedClientAddr(
|
||||
// another client's bucket, by picking an X-Forwarded-For value —
|
||||
// which makes every limit here decorative against a deliberate
|
||||
// attacker.
|
||||
//
|
||||
// The address that identifies the client is then reduced to a bucket
|
||||
// by bucketKey: full address for IPv4, /64 prefix for IPv6.
|
||||
func (m *Middleware) rateLimitKey(r *http.Request) (string, error) {
|
||||
return m.clientKey(r), nil
|
||||
}
|
||||
@@ -152,23 +195,25 @@ func (m *Middleware) clientKey(r *http.Request) string {
|
||||
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr))
|
||||
if err != nil {
|
||||
// Not an address we can reason about; key on the raw
|
||||
// value, the most specific identity left. On a
|
||||
// Unix-socket listener every peer carries the same
|
||||
// RemoteAddr and so shares one bucket, which is the
|
||||
// fail-closed direction.
|
||||
// value, the most specific identity left. Distinct
|
||||
// RemoteAddr values stay in distinct buckets, so this
|
||||
// path cannot silently collapse unrelated clients
|
||||
// together. On a Unix-socket listener every peer
|
||||
// carries the same RemoteAddr and so shares one bucket,
|
||||
// which is the fail-closed direction.
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
peer = normalizeAddr(peer)
|
||||
if !m.isTrustedProxy(peer) {
|
||||
return peer.String()
|
||||
return bucketKey(peer)
|
||||
}
|
||||
|
||||
if addr, ok := m.forwardedClientAddr(r); ok {
|
||||
return addr.String()
|
||||
return bucketKey(addr)
|
||||
}
|
||||
|
||||
return peer.String()
|
||||
return bucketKey(peer)
|
||||
}
|
||||
|
||||
// tooManyRequests returns the 429 handler used by the login,
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
)
|
||||
@@ -370,6 +371,30 @@ const (
|
||||
headerXFF = "X-Forwarded-For"
|
||||
headerReal = "X-Real-IP"
|
||||
headerTrue = "True-Client-IP"
|
||||
|
||||
// clientIPv4 is the sample IPv4 client address these tests key
|
||||
// on, both directly and in IPv4-mapped form. clientIPv4Alt is
|
||||
// its neighbour, used to show the two do not share a bucket.
|
||||
clientIPv4 = "198.51.100.7"
|
||||
clientIPv4Alt = "198.51.100.8"
|
||||
|
||||
// clientIPv6 and clientIPv6Same are two addresses inside one
|
||||
// routed /64, so both must key on clientBucketV6.
|
||||
// clientIPv6Other is a different allocation and must key on
|
||||
// clientOtherBucketV6.
|
||||
clientIPv6 = "2001:db8:1:2:3:4:5:6"
|
||||
clientIPv6Same = "2001:db8:1:2:aaaa:bbbb:cccc:dddd"
|
||||
clientIPv6Other = "2001:db8:1:3::1"
|
||||
clientBucketV6 = "2001:db8:1:2::/64"
|
||||
clientOtherBucketV6 = "2001:db8:1:3::/64"
|
||||
|
||||
// trustedProxyCIDR is the proxy network the forwarded-path
|
||||
// tests configure, and trustedPeer an address inside it. A
|
||||
// production deployment is required to run behind a reverse
|
||||
// proxy with TRUSTED_PROXIES set, so this is the shape the
|
||||
// bucketing has to hold in.
|
||||
trustedProxyCIDR = "10.0.0.0/8"
|
||||
trustedPeer = "10.0.0.1:44444"
|
||||
)
|
||||
|
||||
// assertSharedBucket drives the login limiter from peer with the
|
||||
@@ -458,8 +483,8 @@ func TestRateLimitKey_SingleValuedHeadersIgnoredFromTrustedPeer(
|
||||
t.Parallel()
|
||||
|
||||
assertSharedBucket(
|
||||
t, trustedProxies("10.0.0.0/8"),
|
||||
"10.0.0.1:44444",
|
||||
t, trustedProxies(trustedProxyCIDR),
|
||||
trustedPeer,
|
||||
func(i int) map[string]string {
|
||||
return map[string]string{
|
||||
header: fmt.Sprintf(
|
||||
@@ -495,8 +520,8 @@ func TestRateLimitKey_MalformedRightmostHopFallsBackToPeer(
|
||||
t.Parallel()
|
||||
|
||||
assertSharedBucket(
|
||||
t, trustedProxies("10.0.0.0/8"),
|
||||
"10.0.0.1:44444",
|
||||
t, trustedProxies(trustedProxyCIDR),
|
||||
trustedPeer,
|
||||
func(i int) map[string]string {
|
||||
return map[string]string{
|
||||
headerXFF: fmt.Sprintf(
|
||||
@@ -522,13 +547,13 @@ func TestRateLimitKey_ForwardedHonouredFromTrustedPeer(
|
||||
t.Parallel()
|
||||
|
||||
m := rateLimitMiddleware(t, &config.Config{
|
||||
TrustedProxies: trustedProxies("10.0.0.0/8"),
|
||||
TrustedProxies: trustedProxies(trustedProxyCIDR),
|
||||
})
|
||||
handler := m.LoginRateLimit()(okHandler())
|
||||
|
||||
const peer = "10.0.0.1:44444"
|
||||
const peer = trustedPeer
|
||||
|
||||
first := map[string]string{headerXFF: "198.51.100.7"}
|
||||
first := map[string]string{headerXFF: clientIPv4}
|
||||
|
||||
for range middleware.LoginRateLimitConst {
|
||||
postWithHeaders(handler, peer, loginPath, first)
|
||||
@@ -542,7 +567,7 @@ func TestRateLimitKey_ForwardedHonouredFromTrustedPeer(
|
||||
|
||||
w = postWithHeaders(
|
||||
handler, peer, loginPath,
|
||||
map[string]string{headerXFF: "198.51.100.8"},
|
||||
map[string]string{headerXFF: clientIPv4Alt},
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
@@ -559,7 +584,7 @@ func TestRateLimitKey_ChainWalkSkipsClientPrepended(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertSharedBucket(
|
||||
t, trustedProxies("10.0.0.0/8"), "10.0.0.1:44444",
|
||||
t, trustedProxies(trustedProxyCIDR), trustedPeer,
|
||||
func(i int) map[string]string {
|
||||
return map[string]string{
|
||||
headerXFF: fmt.Sprintf(
|
||||
@@ -594,7 +619,7 @@ func TestRateLimitKey_LongChainCapsWalkAndFallsBackToPeer(
|
||||
start := time.Now()
|
||||
|
||||
assertSharedBucket(
|
||||
t, trustedProxies("10.0.0.0/8"), "10.0.0.1:44444",
|
||||
t, trustedProxies(trustedProxyCIDR), trustedPeer,
|
||||
func(i int) map[string]string {
|
||||
return map[string]string{
|
||||
headerXFF: fmt.Sprintf("9.9.9.%d%s", i+1, padding),
|
||||
@@ -633,13 +658,13 @@ func TestRateLimitKey_LongChainAllocationIsBounded(t *testing.T) {
|
||||
)
|
||||
|
||||
m := rateLimitMiddleware(t, &config.Config{
|
||||
TrustedProxies: trustedProxies("10.0.0.0/8"),
|
||||
TrustedProxies: trustedProxies(trustedProxyCIDR),
|
||||
})
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodPost, loginPath, nil,
|
||||
)
|
||||
req.RemoteAddr = "10.0.0.1:44444"
|
||||
req.RemoteAddr = trustedPeer
|
||||
req.Header.Set(
|
||||
headerXFF, "9.9.9.9"+strings.Repeat(", 10.0.0.2", hops),
|
||||
)
|
||||
@@ -835,3 +860,369 @@ func TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer(
|
||||
"not mint a fresh receiver bucket",
|
||||
)
|
||||
}
|
||||
|
||||
// clientKeyFor returns the bucket key m computes for a request whose
|
||||
// direct peer is remoteAddr and which carries no forwarded headers.
|
||||
func clientKeyFor(
|
||||
t *testing.T, m *middleware.Middleware, remoteAddr string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodPost, loginPath, nil,
|
||||
)
|
||||
req.RemoteAddr = remoteAddr
|
||||
|
||||
return middleware.ClientKeyForTest(m, req)
|
||||
}
|
||||
|
||||
// TestRateLimitKey_IPv6BucketsByPrefix pins the key function's
|
||||
// address-family behaviour. IPv6 clients must bucket by /64 — a
|
||||
// routed /64 is the normal residential and mobile allocation, so
|
||||
// per-/128 keying lets one subscriber rotate source addresses and
|
||||
// mint a fresh bucket per request — while IPv4 keeps keying on the
|
||||
// full address and IPv4-mapped form is keyed as the IPv4 address it
|
||||
// carries.
|
||||
func TestRateLimitKey_IPv6BucketsByPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := rateLimitMiddleware(t, &config.Config{})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
peer string
|
||||
want string
|
||||
about string
|
||||
}{{
|
||||
name: "ipv6",
|
||||
peer: "[" + clientIPv6 + "]:44444",
|
||||
want: clientBucketV6,
|
||||
about: "an IPv6 peer must key on its /64",
|
||||
}, {
|
||||
name: "ipv6-other-in-same-64",
|
||||
peer: "[" + clientIPv6Same + "]:1",
|
||||
want: clientBucketV6,
|
||||
about: "another address in the same /64 must key the same",
|
||||
}, {
|
||||
name: "ipv6-different-64",
|
||||
peer: "[" + clientIPv6Other + "]:44444",
|
||||
want: clientOtherBucketV6,
|
||||
about: "a different /64 must key differently",
|
||||
}, {
|
||||
name: "ipv4",
|
||||
peer: clientIPv4 + ":44444",
|
||||
want: clientIPv4,
|
||||
about: "IPv4 must keep keying on the full address",
|
||||
}, {
|
||||
name: "ipv4-neighbour",
|
||||
peer: clientIPv4Alt + ":44444",
|
||||
want: clientIPv4Alt,
|
||||
about: "adjacent IPv4 addresses must not share a bucket",
|
||||
}, {
|
||||
name: "ipv4-mapped",
|
||||
peer: "[::ffff:" + clientIPv4 + "]:44444",
|
||||
want: clientIPv4,
|
||||
about: "IPv4-mapped form must key as the IPv4 address, " +
|
||||
"not be masked to a /64: mapped addresses all share " +
|
||||
"::ffff:0:0/96, so masking would collapse every IPv4 " +
|
||||
"client behind a mapping proxy into one bucket",
|
||||
}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t, tc.want, clientKeyFor(t, m, tc.peer), tc.about,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitKey_FamiliesDoNotCollide pins the structure the
|
||||
// no-collision property rests on, rather than one sample pair: every
|
||||
// IPv4 key is a bare address and every IPv6 key is a /64 in CIDR
|
||||
// form, so the two name spaces are disjoint by shape. Dropping the
|
||||
// masking strips the suffix that guarantees it, which is why this
|
||||
// asserts the form of each key and not just that two of them differ.
|
||||
func TestRateLimitKey_FamiliesDoNotCollide(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Restated here rather than imported from the package under
|
||||
// test, so that changing the production bucket width fails this
|
||||
// test instead of silently moving with it.
|
||||
const wantBits = 64
|
||||
|
||||
m := rateLimitMiddleware(t, &config.Config{})
|
||||
|
||||
v4Keys := map[string]bool{}
|
||||
|
||||
for _, peer := range []string{
|
||||
clientIPv4 + ":44444",
|
||||
clientIPv4Alt + ":44444",
|
||||
"[::ffff:" + clientIPv4 + "]:44444",
|
||||
} {
|
||||
key := clientKeyFor(t, m, peer)
|
||||
|
||||
addr, err := netip.ParseAddr(key)
|
||||
require.NoError(
|
||||
t, err, "%s: an IPv4 key must be a bare address", peer,
|
||||
)
|
||||
assert.True(
|
||||
t, addr.Is4(),
|
||||
"%s: an IPv4 key must be a dotted quad, got %q", peer, key,
|
||||
)
|
||||
|
||||
v4Keys[key] = true
|
||||
}
|
||||
|
||||
for _, peer := range []string{
|
||||
"[" + clientIPv6 + "]:44444",
|
||||
"[" + clientIPv6Same + "]:44444",
|
||||
"[" + clientIPv6Other + "]:44444",
|
||||
"[2001:db8::" + clientIPv4 + "]:44444",
|
||||
} {
|
||||
key := clientKeyFor(t, m, peer)
|
||||
|
||||
prefix, err := netip.ParsePrefix(key)
|
||||
require.NoError(
|
||||
t, err, "%s: an IPv6 key must be a CIDR prefix", peer,
|
||||
)
|
||||
assert.Equal(
|
||||
t, wantBits, prefix.Bits(),
|
||||
"%s: an IPv6 key must name a /64", peer,
|
||||
)
|
||||
assert.False(
|
||||
t, v4Keys[key],
|
||||
"%s: an IPv6 key must never equal an IPv4 key", peer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitKey_UnparseablePeerKeepsDistinctBuckets covers the
|
||||
// fallback path. A RemoteAddr that is not an address must not panic,
|
||||
// and must not drop unrelated clients into one shared bucket by
|
||||
// accident: the raw value is the most specific identity left, so
|
||||
// distinct values stay in distinct buckets.
|
||||
func TestRateLimitKey_UnparseablePeerKeepsDistinctBuckets(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
m := rateLimitMiddleware(t, &config.Config{})
|
||||
|
||||
first := clientKeyFor(t, m, "not-an-address")
|
||||
second := clientKeyFor(t, m, "also-not-an-address:1234")
|
||||
|
||||
assert.NotEmpty(t, first)
|
||||
assert.NotEqual(
|
||||
t, first, second,
|
||||
"unparseable peers must not collapse into one bucket",
|
||||
)
|
||||
}
|
||||
|
||||
// TestLoginRateLimit_IPv6SharesBucketWithinSlash64 is the behavioural
|
||||
// half, and the regression test for the bypass itself: a client that
|
||||
// rotates source addresses inside its own routed /64 must stay in one
|
||||
// bucket. Reverting the masking makes this test fail, because each
|
||||
// rotated address would mint a fresh bucket and nothing would be
|
||||
// rejected.
|
||||
func TestLoginRateLimit_IPv6SharesBucketWithinSlash64(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := rateLimitMiddleware(t, &config.Config{})
|
||||
handler := m.LoginRateLimit()(okHandler())
|
||||
|
||||
for i := range middleware.LoginRateLimitConst {
|
||||
w := postWithHeaders(
|
||||
handler,
|
||||
fmt.Sprintf("[2001:db8:1:2::%d]:44444", i+1),
|
||||
loginPath, nil,
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code, "request %d should pass", i,
|
||||
)
|
||||
}
|
||||
|
||||
w := postWithHeaders(
|
||||
handler, "[2001:db8:1:2::ffff]:44444", loginPath, nil,
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusTooManyRequests, w.Code,
|
||||
"rotating source addresses inside one routed /64 must not "+
|
||||
"mint fresh buckets",
|
||||
)
|
||||
}
|
||||
|
||||
// TestLoginRateLimit_IPv6IndependentAcrossSlash64 is the other side
|
||||
// of the trade: bucketing by /64 must not merge separate allocations,
|
||||
// so a client in a different /64 keeps its own limit.
|
||||
func TestLoginRateLimit_IPv6IndependentAcrossSlash64(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := rateLimitMiddleware(t, &config.Config{})
|
||||
handler := m.LoginRateLimit()(okHandler())
|
||||
|
||||
for range middleware.LoginRateLimitConst + 1 {
|
||||
postWithHeaders(
|
||||
handler, "[2001:db8:1:2::1]:44444", loginPath, nil,
|
||||
)
|
||||
}
|
||||
|
||||
w := postWithHeaders(
|
||||
handler, "[2001:db8:1:3::1]:44444", loginPath, nil,
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
"a different /64 must have its own bucket",
|
||||
)
|
||||
}
|
||||
|
||||
// TestLoginRateLimit_IPv4IndependentPerAddress guards against the
|
||||
// masking leaking into IPv4: two addresses one apart must still hold
|
||||
// separate buckets.
|
||||
func TestLoginRateLimit_IPv4IndependentPerAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := rateLimitMiddleware(t, &config.Config{})
|
||||
handler := m.LoginRateLimit()(okHandler())
|
||||
|
||||
for range middleware.LoginRateLimitConst + 1 {
|
||||
postWithHeaders(
|
||||
handler, clientIPv4+":44444", loginPath, nil,
|
||||
)
|
||||
}
|
||||
|
||||
w := postWithHeaders(
|
||||
handler, clientIPv4Alt+":44444", loginPath, nil,
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
"a second IPv4 address must have its own bucket",
|
||||
)
|
||||
}
|
||||
|
||||
// forwardedKeyFor returns the bucket key m computes for a request
|
||||
// that arrives from trustedPeer — a configured trusted proxy — and
|
||||
// names forwarded as its client in X-Forwarded-For. That is the
|
||||
// production path: a deployment is required to run behind a reverse
|
||||
// proxy with TRUSTED_PROXIES set, so the forwarded address, not the
|
||||
// peer, is what the limiters bucket on there.
|
||||
func forwardedKeyFor(
|
||||
t *testing.T, m *middleware.Middleware, forwarded string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodPost, loginPath, nil,
|
||||
)
|
||||
req.RemoteAddr = trustedPeer
|
||||
req.Header.Set(headerXFF, forwarded)
|
||||
|
||||
return middleware.ClientKeyForTest(m, req)
|
||||
}
|
||||
|
||||
// TestRateLimitKey_ForwardedIPv6BucketsByPrefix pins the /64
|
||||
// bucketing on the trusted-proxy branch. The direct-peer tests above
|
||||
// cannot reach it, so without this the masking could be reverted for
|
||||
// forwarded clients alone — the only shape a production deployment
|
||||
// runs in — and the rest of the suite would stay green.
|
||||
func TestRateLimitKey_ForwardedIPv6BucketsByPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := rateLimitMiddleware(t, &config.Config{
|
||||
TrustedProxies: trustedProxies(trustedProxyCIDR),
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
forwarded string
|
||||
want string
|
||||
about string
|
||||
}{{
|
||||
name: "ipv6",
|
||||
forwarded: clientIPv6,
|
||||
want: clientBucketV6,
|
||||
about: "a forwarded IPv6 client must key on its /64",
|
||||
}, {
|
||||
name: "ipv6-other-in-same-64",
|
||||
forwarded: clientIPv6Same,
|
||||
want: clientBucketV6,
|
||||
about: "another forwarded address in the same /64 must " +
|
||||
"key the same",
|
||||
}, {
|
||||
name: "ipv6-different-64",
|
||||
forwarded: clientIPv6Other,
|
||||
want: clientOtherBucketV6,
|
||||
about: "a forwarded address in another /64 must differ",
|
||||
}, {
|
||||
name: "ipv4",
|
||||
forwarded: clientIPv4,
|
||||
want: clientIPv4,
|
||||
about: "a forwarded IPv4 client must key on the address",
|
||||
}, {
|
||||
name: "ipv4-mapped",
|
||||
forwarded: "::ffff:" + clientIPv4,
|
||||
want: clientIPv4,
|
||||
about: "a proxy that forwards IPv4-mapped form must key as " +
|
||||
"the IPv4 address it carries, not be masked to a /64: " +
|
||||
"mapped addresses all share ::ffff:0:0/96",
|
||||
}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t, tc.want,
|
||||
forwardedKeyFor(t, m, tc.forwarded), tc.about,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginRateLimit_ForwardedIPv6SharesBucketWithinSlash64 is the
|
||||
// behavioural half on the production path: behind a trusted proxy, a
|
||||
// client rotating source addresses inside its own routed /64 must
|
||||
// stay in one bucket.
|
||||
func TestLoginRateLimit_ForwardedIPv6SharesBucketWithinSlash64(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
assertSharedBucket(
|
||||
t, trustedProxies(trustedProxyCIDR), trustedPeer,
|
||||
func(i int) map[string]string {
|
||||
return map[string]string{
|
||||
headerXFF: fmt.Sprintf("2001:db8:1:2::%d", i+1),
|
||||
}
|
||||
},
|
||||
"rotating forwarded source addresses inside one routed /64 "+
|
||||
"must not mint fresh buckets",
|
||||
)
|
||||
}
|
||||
|
||||
// TestLoginRateLimit_ForwardedIPv6IndependentAcrossSlash64 is the
|
||||
// other side of that trade on the same path: bucketing by /64 must
|
||||
// not merge two allocations reaching the proxy.
|
||||
func TestLoginRateLimit_ForwardedIPv6IndependentAcrossSlash64(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
m := rateLimitMiddleware(t, &config.Config{
|
||||
TrustedProxies: trustedProxies(trustedProxyCIDR),
|
||||
})
|
||||
handler := m.LoginRateLimit()(okHandler())
|
||||
|
||||
spent := map[string]string{headerXFF: clientIPv6}
|
||||
for range middleware.LoginRateLimitConst + 1 {
|
||||
postWithHeaders(handler, trustedPeer, loginPath, spent)
|
||||
}
|
||||
|
||||
w := postWithHeaders(
|
||||
handler, trustedPeer, loginPath,
|
||||
map[string]string{headerXFF: clientIPv6Other},
|
||||
)
|
||||
assert.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
"a forwarded client in a different /64 must have its own "+
|
||||
"bucket",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -146,6 +146,15 @@ func (s *Server) setupSourceRoutes() {
|
||||
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
||||
r.Post("/delete", s.h.HandleSourceDelete())
|
||||
r.Get("/logs", s.h.HandleSourceLogs())
|
||||
// The log page renders each body only up to its cap, so
|
||||
// this is the only route that serves a whole one. It
|
||||
// belongs to this group for its RequireAuth and
|
||||
// NoCache; see HandleEventBodyDownload for the headers
|
||||
// that keep the bytes it returns inert.
|
||||
r.Get(
|
||||
"/logs/{eventID}/body",
|
||||
s.h.HandleEventBodyDownload(),
|
||||
)
|
||||
r.Post(
|
||||
"/entrypoints",
|
||||
s.h.HandleEntrypointCreate(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"gorm.io/gorm/clause"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
@@ -49,6 +51,7 @@ type testEnv struct {
|
||||
router http.Handler
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
}
|
||||
|
||||
// newTestEnv wires the dependency graph with fx and builds the
|
||||
@@ -58,12 +61,13 @@ func newTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
var (
|
||||
log *logger.Logger
|
||||
cfg *config.Config
|
||||
mw *middleware.Middleware
|
||||
hnd *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
log *logger.Logger
|
||||
cfg *config.Config
|
||||
mw *middleware.Middleware
|
||||
hnd *handlers.Handlers
|
||||
sess *session.Session
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
)
|
||||
|
||||
app := fxtest.New(
|
||||
@@ -86,7 +90,7 @@ func newTestEnv(t *testing.T) *testEnv {
|
||||
middleware.New,
|
||||
handlers.New,
|
||||
),
|
||||
fx.Populate(&log, &cfg, &mw, &hnd, &sess, &db),
|
||||
fx.Populate(&log, &cfg, &mw, &hnd, &sess, &db, &dbMgr),
|
||||
)
|
||||
app.RequireStart()
|
||||
t.Cleanup(app.RequireStop)
|
||||
@@ -95,6 +99,7 @@ func newTestEnv(t *testing.T) *testEnv {
|
||||
router: server.NewRouterForTest(log.Get(), cfg, mw, hnd),
|
||||
sess: sess,
|
||||
db: db,
|
||||
dbMgr: dbMgr,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +238,49 @@ func (e *testEnv) seedUser(
|
||||
return user.ID, hash
|
||||
}
|
||||
|
||||
// seedWebhook creates a webhook owned by the given user.
|
||||
func (e *testEnv) seedWebhook(
|
||||
t *testing.T,
|
||||
userID string,
|
||||
) *database.Webhook {
|
||||
t.Helper()
|
||||
|
||||
wh := &database.Webhook{UserID: userID, Name: "routed"}
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
e.db.DB().Omit(clause.Associations).Create(wh).Error,
|
||||
)
|
||||
|
||||
return wh
|
||||
}
|
||||
|
||||
// seedEvent records one event with the given body in a webhook's
|
||||
// own database.
|
||||
func (e *testEnv) seedEvent(
|
||||
t *testing.T,
|
||||
webhookID, body string,
|
||||
) *database.Event {
|
||||
t.Helper()
|
||||
|
||||
webhookDB, err := e.dbMgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
event := &database.Event{
|
||||
WebhookID: webhookID,
|
||||
Method: http.MethodPost,
|
||||
Body: body,
|
||||
ContentType: "application/octet-stream",
|
||||
}
|
||||
|
||||
require.NoError(
|
||||
t,
|
||||
webhookDB.Omit(clause.Associations).Create(event).Error,
|
||||
)
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// storedHash reads the current password hash for a username.
|
||||
func (e *testEnv) storedHash(t *testing.T, username string) string {
|
||||
t.Helper()
|
||||
@@ -432,3 +480,95 @@ func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
|
||||
"an under-limit password change should still apply",
|
||||
)
|
||||
}
|
||||
|
||||
// --- /source/{sourceID} group ---
|
||||
|
||||
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
||||
// feature the way a user does: render the event log page through
|
||||
// the production router, take the download URL out of the markup
|
||||
// the template emitted, and fetch that URL through the router
|
||||
// again. Nothing here is hand-written, so a typo in either the
|
||||
// route pattern or the template href fails this test — the
|
||||
// handler-level tests cannot catch that, because they forge
|
||||
// their own route context and assert a URL string they wrote
|
||||
// themselves.
|
||||
func TestSourceLogs_TruncationLinkDownloadsTheBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
userID, _ := env.seedUser(t, "loguser", "somepassword")
|
||||
cookies := env.authCookies(t, userID, "loguser")
|
||||
|
||||
// Comfortably over the event log page's render cap, so the
|
||||
// page truncates the body and renders the download link at
|
||||
// all. The exact cap is the handlers package's business and
|
||||
// is pinned by its own tests; this only needs to exceed it.
|
||||
stored := strings.Repeat("Z", 64*1024)
|
||||
|
||||
wh := env.seedWebhook(t, userID)
|
||||
env.seedEvent(t, wh.ID, stored)
|
||||
|
||||
page := env.get("/source/"+wh.ID+"/logs", cookies)
|
||||
require.Equal(t, http.StatusOK, page.Code)
|
||||
|
||||
link := regexp.MustCompile(
|
||||
`href="(/source/[^"]+/body)"`,
|
||||
).FindStringSubmatch(page.Body.String())
|
||||
require.Len(
|
||||
t, link, 2,
|
||||
"truncated body should render a download link",
|
||||
)
|
||||
|
||||
w := env.get(html.UnescapeString(link[1]), cookies)
|
||||
|
||||
require.Equal(
|
||||
t, http.StatusOK, w.Code,
|
||||
"the link the page emits must be a live route",
|
||||
)
|
||||
assert.Equal(t, stored, w.Body.String())
|
||||
assert.Equal(
|
||||
t, strconv.Itoa(len(stored)),
|
||||
w.Header().Get("Content-Length"),
|
||||
)
|
||||
assert.Equal(
|
||||
t, "application/octet-stream",
|
||||
w.Header().Get("Content-Type"),
|
||||
)
|
||||
assert.Contains(
|
||||
t, w.Header().Get("Content-Disposition"), "attachment",
|
||||
)
|
||||
assert.Equal(
|
||||
t, "nosniff", w.Header().Get("X-Content-Type-Options"),
|
||||
)
|
||||
}
|
||||
|
||||
// TestSourceLogsBody_OtherUser404s pins that the download route
|
||||
// as registered is behind the auth the group provides and the
|
||||
// ownership check the handler applies: another logged-in user
|
||||
// asking the real router for the same URL gets a 404, and an
|
||||
// unauthenticated request never reaches the handler at all.
|
||||
func TestSourceLogsBody_OtherUser404s(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newTestEnv(t)
|
||||
|
||||
ownerID, _ := env.seedUser(t, "owner", "somepassword")
|
||||
wh := env.seedWebhook(t, ownerID)
|
||||
|
||||
const payload = "OWNERS-PAYLOAD-77c1"
|
||||
|
||||
evt := env.seedEvent(t, wh.ID, payload)
|
||||
path := "/source/" + wh.ID + "/logs/" + evt.ID + "/body"
|
||||
|
||||
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
|
||||
intruder := env.authCookies(t, intruderID, "intruder")
|
||||
|
||||
w := env.get(path, intruder)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.NotContains(t, w.Body.String(), payload)
|
||||
|
||||
anon := env.get(path, nil)
|
||||
assert.Equal(t, http.StatusSeeOther, anon.Code)
|
||||
assert.Equal(t, "/pages/login", anon.Header().Get("Location"))
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
|
||||
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
||||
{{if .BodyTruncated}}
|
||||
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged.</p>
|
||||
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged — <a href="/source/{{$.Webhook.ID}}/logs/{{.ID}}/body" class="text-primary-600 hover:text-primary-700 underline">download the full body</a>.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user