check / check (push) Successful in 4m8s
Usernames are limited to 1024 bytes, so no account can exist that is unable to log in. The username rides in the session cookie, which browsers and securecookie refuse past about 4 KB, leaving room for roughly 2000 bytes of username; the limit is about half that. User.BeforeSave returns ErrUsernameTooLong when a whole User is created or saved. A byte-counting check constraint on users.username catches every other write, including a column update. The limit appears in the constant and in the struct tag; a test fails if they disagree. Model: opus-5-5
542 lines
15 KiB
Go
542 lines
15 KiB
Go
package handlers_test
|
|
|
|
// The handler-side half of the log-field audit. Two slog calls in
|
|
// this package reach a value an UNAUTHENTICATED client picks outright
|
|
// and of a length it picks outright:
|
|
//
|
|
// - the unknown-entrypoint DEBUG line on /webhook/{uuid}, whose
|
|
// path segment matched no stored entrypoint and so is bounded by
|
|
// nothing;
|
|
// - the failed-login DEBUG lines, whose username is a form field.
|
|
//
|
|
// Both are at DEBUG, which is off in production by default. That is
|
|
// not a bound: an operator turning DEBUG on to diagnose a flood must
|
|
// not thereby hand the flood an unbounded write. Both spend the same
|
|
// internal/logfield budget as the access log, and both are held here
|
|
// to middleware.MaxAccessLogLineBytes.
|
|
//
|
|
// The two login lines past the username lookup — "invalid password"
|
|
// and "user logged in" — carry the same cap without needing it, since
|
|
// by then the value is a stored row rather than the client's. They are
|
|
// pinned here too, so the caps cannot be dropped silently.
|
|
//
|
|
// So is the "password verification capacity exhausted" WARN line,
|
|
// whose path chi pins to the constant "/pages/login" on the one route
|
|
// that reaches it. Its cap is defensive, and the test below drives the
|
|
// handler directly with the path a parameterised route would give it,
|
|
// because an unasserted cap is one a later edit removes for free.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
"sneak.berlin/go/webhooker/internal/middleware"
|
|
)
|
|
|
|
// floodRequests is the number of distinct invented values each flood
|
|
// drives through the call site under test.
|
|
const floodRequests = 32
|
|
|
|
// oversizedFillBytes is the length of the single client-chosen value
|
|
// used to show that line size does not track input size.
|
|
const oversizedFillBytes = 8192
|
|
|
|
// attackerMarker and tailMarker sit at the END of every oversized
|
|
// value, past every budget. Their absence from the log is what
|
|
// proves the value was cut rather than merely being short.
|
|
const (
|
|
attackerMarker = "QQATTACKERTEXTQQ"
|
|
tailMarker = "QQTRUNCATEDTAILQQ"
|
|
)
|
|
|
|
// escapeFills are the characters the log handlers escape, so a value
|
|
// built out of them costs more on the line than it did on the wire. A
|
|
// budget counted in raw bytes passes the plain case and fails these.
|
|
//
|
|
// U+1000C is unassigned, hence non-printable, and strconv.Quote
|
|
// spells it as a ten-byte \UXXXXXXXX while the JSON handler passes
|
|
// its four UTF-8 bytes through; only the text shape of these tests
|
|
// reaches that charge.
|
|
func escapeFills() map[string]string {
|
|
return map[string]string{
|
|
"plain": "x",
|
|
"quote": `"`,
|
|
"backslash": `\`,
|
|
"tab": "\t",
|
|
"newline": "\n",
|
|
// A C0 control neither handler has a short escape for, so
|
|
// each one costs six bytes on the line against the single
|
|
// byte it cost to send: the widest multiplier a client can
|
|
// drive, and the case a raw-byte budget breaks on first.
|
|
//
|
|
// This fill is load-bearing, not decoration. Budgeting raw
|
|
// bytes instead of encoded is caught by this fill alone,
|
|
// and only under the JSON handler, at 3,072 bytes against
|
|
// the 2,560 ceiling. Drop it and that mutation passes.
|
|
"control": "\x01",
|
|
"astral": "\U0001000C",
|
|
}
|
|
}
|
|
|
|
// logHandlers are the two handlers internal/logger can install.
|
|
func logHandlers() map[string]func(
|
|
io.Writer, *slog.HandlerOptions,
|
|
) slog.Handler {
|
|
return map[string]func(
|
|
io.Writer, *slog.HandlerOptions,
|
|
) slog.Handler{
|
|
"json": func(
|
|
w io.Writer, o *slog.HandlerOptions,
|
|
) slog.Handler {
|
|
return slog.NewJSONHandler(w, o)
|
|
},
|
|
"text": func(
|
|
w io.Writer, o *slog.HandlerOptions,
|
|
) slog.Handler {
|
|
return slog.NewTextHandler(w, o)
|
|
},
|
|
}
|
|
}
|
|
|
|
// oversizedFill builds an 8 KB client-chosen value out of
|
|
// repetitions of ch, with both markers at its far end.
|
|
func oversizedFill(ch string) string {
|
|
return "x" + strings.Repeat(ch, oversizedFillBytes) +
|
|
attackerMarker + tailMarker
|
|
}
|
|
|
|
// capturingHandlers builds a Handlers whose log is captured into the
|
|
// returned buffer at DEBUG through the named handler.
|
|
//
|
|
// extra is passed to fx.Populate alongside the Handlers, for the call
|
|
// sites that also need the database the client's value is looked up
|
|
// in, or the Middleware whose resource has to be exhausted before the
|
|
// branch under test is reached.
|
|
func capturingHandlers(
|
|
t *testing.T,
|
|
newHandler func(io.Writer, *slog.HandlerOptions) slog.Handler,
|
|
extra ...any,
|
|
) (*handlers.Handlers, *bytes.Buffer) {
|
|
t.Helper()
|
|
|
|
var h *handlers.Handlers
|
|
|
|
app := newTestApp(t, append([]any{&h}, extra...)...)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
buf := new(bytes.Buffer)
|
|
h.SetLogForTest(slog.New(newHandler(
|
|
buf, &slog.HandlerOptions{Level: slog.LevelDebug},
|
|
)))
|
|
|
|
return h, buf
|
|
}
|
|
|
|
// logLines splits the captured buffer into non-empty lines, holding
|
|
// each to the stated per-line ceiling.
|
|
func logLines(t *testing.T, buf *bytes.Buffer) []string {
|
|
t.Helper()
|
|
|
|
var lines []string
|
|
|
|
for line := range strings.SplitSeq(
|
|
strings.TrimSpace(buf.String()), "\n",
|
|
) {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
require.LessOrEqual(
|
|
t, len(line), middleware.MaxAccessLogLineBytes,
|
|
"log line exceeded its bound: %s", line,
|
|
)
|
|
|
|
lines = append(lines, line)
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
// assertNoClientText fails if the far end of the client-chosen input
|
|
// survived into the log.
|
|
func assertNoClientText(t *testing.T, buf *bytes.Buffer) {
|
|
t.Helper()
|
|
|
|
assert.NotContains(
|
|
t, buf.String(), attackerMarker,
|
|
"log carried attacker-chosen text",
|
|
)
|
|
assert.NotContains(
|
|
t, buf.String(), tailMarker,
|
|
"log carried the tail of the attacker-chosen text",
|
|
)
|
|
}
|
|
|
|
// receiverRouter mounts the real receiver handler at the production
|
|
// route pattern.
|
|
func receiverRouter(h *handlers.Handlers) *chi.Mux {
|
|
router := chi.NewRouter()
|
|
router.Post("/webhook/{uuid}", h.HandleWebhook())
|
|
|
|
return router
|
|
}
|
|
|
|
// postReceiver sends one POST at /webhook/<segment>.
|
|
//
|
|
// RawPath is cleared after parsing so chi routes on the decoded path
|
|
// and the handler sees the raw bytes rather than their percent-escaped
|
|
// spelling. That is the harder case for the budget: the escaped
|
|
// spelling is plain ASCII, which costs one byte per byte, while the
|
|
// decoded bytes are what the log handler has to escape.
|
|
func postReceiver(
|
|
t *testing.T, router *chi.Mux, segment string,
|
|
) int {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost,
|
|
"/webhook/"+url.PathEscape(segment),
|
|
strings.NewReader(""),
|
|
)
|
|
req.URL.RawPath = ""
|
|
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
return w.Code
|
|
}
|
|
|
|
// postLogin submits the login form with the given username and a
|
|
// non-empty password.
|
|
func postLogin(
|
|
t *testing.T, h *handlers.Handlers, username string,
|
|
) int {
|
|
t.Helper()
|
|
|
|
return postLoginWithPassword(t, h, username, "not-the-password")
|
|
}
|
|
|
|
// postLoginWithPassword submits the login form with both credentials
|
|
// chosen by the caller, so a test can reach the branches past the
|
|
// username lookup.
|
|
func postLoginWithPassword(
|
|
t *testing.T, h *handlers.Handlers, username, password string,
|
|
) int {
|
|
t.Helper()
|
|
|
|
form := url.Values{
|
|
"username": {username},
|
|
"password": {password},
|
|
}
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost,
|
|
"/pages/login",
|
|
strings.NewReader(form.Encode()),
|
|
)
|
|
req.Header.Set(
|
|
"Content-Type", "application/x-www-form-urlencoded",
|
|
)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.HandleLoginSubmit().ServeHTTP(w, req)
|
|
|
|
return w.Code
|
|
}
|
|
|
|
// TestUnknownEntrypoint_LogLineDoesNotTrackPathSize drives 8 KB of
|
|
// client-chosen path at the unauthenticated receiver's
|
|
// unknown-entrypoint DEBUG line and holds it to the same ceiling the
|
|
// access log states.
|
|
func TestUnknownEntrypoint_LogLineDoesNotTrackPathSize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for handlerName, newHandler := range logHandlers() {
|
|
for fillName, fill := range escapeFills() {
|
|
t.Run(handlerName+"/"+fillName, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h, buf := capturingHandlers(t, newHandler)
|
|
router := receiverRouter(h)
|
|
|
|
for i := range floodRequests {
|
|
assert.Equal(
|
|
t,
|
|
http.StatusNotFound,
|
|
postReceiver(
|
|
t, router,
|
|
oversizedFill(fill)+
|
|
strings.Repeat("y", i),
|
|
),
|
|
)
|
|
}
|
|
|
|
lines := logLines(t, buf)
|
|
require.Len(t, lines, floodRequests)
|
|
assertNoClientText(t, buf)
|
|
|
|
assertBoundedFlood(t, buf.Len())
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFailedLogin_LogLineDoesNotTrackUsernameSize drives 8 KB of
|
|
// client-chosen username at the unauthenticated login endpoint's
|
|
// DEBUG line and holds it to the same ceiling.
|
|
func TestFailedLogin_LogLineDoesNotTrackUsernameSize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for handlerName, newHandler := range logHandlers() {
|
|
for fillName, fill := range escapeFills() {
|
|
t.Run(handlerName+"/"+fillName, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h, buf := capturingHandlers(t, newHandler)
|
|
|
|
for i := range floodRequests {
|
|
assert.Equal(
|
|
t,
|
|
http.StatusUnauthorized,
|
|
postLogin(
|
|
t, h,
|
|
oversizedFill(fill)+
|
|
strings.Repeat("y", i),
|
|
),
|
|
)
|
|
}
|
|
|
|
lines := logLines(t, buf)
|
|
require.Len(t, lines, floodRequests)
|
|
assertNoClientText(t, buf)
|
|
|
|
assertBoundedFlood(t, buf.Len())
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// storedUserPassword is the password held by the oversize accounts
|
|
// the test below creates.
|
|
const storedUserPassword = "correct-horse-battery-staple"
|
|
|
|
// storedFillBytes is the raw length of the client-chosen value in
|
|
// those accounts' usernames. It is well past the 512-byte field
|
|
// budget, so the line is still truncated, but short enough that the
|
|
// whole username, markers and fill name included, stays within
|
|
// database.MaxUsernameBytes.
|
|
const storedFillBytes = 960
|
|
|
|
// storedFill builds a username fill of storedFillBytes raw bytes out
|
|
// of repetitions of ch, with both markers at its far end.
|
|
func storedFill(ch string) string {
|
|
return "x" + strings.Repeat(ch, storedFillBytes/len(ch)) +
|
|
attackerMarker + tailMarker
|
|
}
|
|
|
|
// TestStoredUsername_LogLinesDoNotTrackUsernameSize pins the two
|
|
// login lines that are reached only AFTER the username matched a
|
|
// stored row: "invalid password" and "user logged in". Neither
|
|
// strictly needs its cap — the value is the operator's own data by
|
|
// then, not the client's — but both carry one so that every username
|
|
// this unauthenticated endpoint logs is capped, and an unasserted cap
|
|
// is one a later edit removes for free.
|
|
//
|
|
// One app per handler with the accounts created inside it, and no
|
|
// parallelism below that level: every account costs an Argon2id hash
|
|
// and every attempt costs a verification.
|
|
func TestStoredUsername_LogLinesDoNotTrackUsernameSize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for handlerName, newHandler := range logHandlers() {
|
|
t.Run(handlerName, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var db *database.Database
|
|
|
|
h, buf := capturingHandlers(t, newHandler, &db)
|
|
|
|
hash, err := database.HashPassword(storedUserPassword)
|
|
require.NoError(t, err)
|
|
|
|
fills := escapeFills()
|
|
for fillName, fill := range fills {
|
|
username := storedFill(fill) + fillName
|
|
|
|
require.NoError(t, db.DB().Create(&database.User{
|
|
Username: username,
|
|
Password: hash,
|
|
}).Error)
|
|
|
|
// Matched the row, wrong secret: "invalid
|
|
// password".
|
|
assert.Equal(
|
|
t, http.StatusUnauthorized,
|
|
postLoginWithPassword(
|
|
t, h, username, "not-the-password",
|
|
),
|
|
)
|
|
|
|
// Matched the row, right secret: "user logged
|
|
// in".
|
|
assert.Equal(
|
|
t, http.StatusSeeOther,
|
|
postLoginWithPassword(
|
|
t, h, username, storedUserPassword,
|
|
),
|
|
)
|
|
}
|
|
|
|
lines := logLines(t, buf)
|
|
require.Len(t, lines, 2*len(fills))
|
|
assertNoClientText(t, buf)
|
|
})
|
|
}
|
|
}
|
|
|
|
// maxVerificationSlots bounds how many slots the loop below will
|
|
// take before it gives up, so a semaphore that never fills fails the
|
|
// test instead of hanging it. It is deliberately larger than the
|
|
// real concurrency bound, which is not exported to this package.
|
|
const maxVerificationSlots = 64
|
|
|
|
// canceledContext returns a context that is already done. A
|
|
// verification request carrying one takes the ctx.Done() branch of
|
|
// the semaphore's bounded wait immediately, so these cases turn on
|
|
// the semaphore being full rather than on a five-second timer firing.
|
|
// Nothing here is timing-dependent.
|
|
func canceledContext() context.Context {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
return ctx
|
|
}
|
|
|
|
// holdEveryVerificationSlot takes verification slots until one is
|
|
// refused, and releases them when the test ends. A free slot is
|
|
// handed out before any context is consulted, so a canceled context
|
|
// cannot make this loop stop early: it stops exactly when the slots
|
|
// are gone.
|
|
func holdEveryVerificationSlot(
|
|
t *testing.T, mw *middleware.Middleware,
|
|
) {
|
|
t.Helper()
|
|
|
|
for range maxVerificationSlots {
|
|
release, ok := mw.BeginPasswordVerification(canceledContext())
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
t.Cleanup(release)
|
|
}
|
|
|
|
require.Fail(t, "the verification semaphore never filled")
|
|
}
|
|
|
|
// postLoginAtPath submits the login form at a path of the caller's
|
|
// choosing, with a canceled context.
|
|
func postLoginAtPath(
|
|
t *testing.T, h *handlers.Handlers, path string,
|
|
) int {
|
|
t.Helper()
|
|
|
|
form := url.Values{
|
|
"username": {"someone"},
|
|
"password": {"not-the-password"},
|
|
}
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
canceledContext(),
|
|
http.MethodPost,
|
|
path,
|
|
strings.NewReader(form.Encode()),
|
|
)
|
|
req.Header.Set(
|
|
"Content-Type", "application/x-www-form-urlencoded",
|
|
)
|
|
|
|
w := httptest.NewRecorder()
|
|
h.HandleLoginSubmit().ServeHTTP(w, req)
|
|
|
|
return w.Code
|
|
}
|
|
|
|
// TestVerificationCapacity_LogLineDoesNotTrackPathSize pins the cap
|
|
// on the "password verification capacity exhausted" WARN line.
|
|
//
|
|
// The one route that reaches it is chi's static "/pages/login", so no
|
|
// request through the mux can widen the line; the handler is driven
|
|
// directly here with the path a parameterised route would give it,
|
|
// which is what that cap exists for. Without this test, removing the
|
|
// logfield.Truncate there fails nothing.
|
|
func TestVerificationCapacity_LogLineDoesNotTrackPathSize(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
for handlerName, newHandler := range logHandlers() {
|
|
for fillName, fill := range escapeFills() {
|
|
t.Run(handlerName+"/"+fillName, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var mw *middleware.Middleware
|
|
|
|
h, buf := capturingHandlers(t, newHandler, &mw)
|
|
|
|
holdEveryVerificationSlot(t, mw)
|
|
|
|
assert.Equal(
|
|
t,
|
|
http.StatusServiceUnavailable,
|
|
postLoginAtPath(
|
|
t, h,
|
|
"/source/"+url.PathEscape(
|
|
oversizedFill(fill),
|
|
)+"/login",
|
|
),
|
|
)
|
|
|
|
lines := logLines(t, buf)
|
|
require.Len(t, lines, 1)
|
|
assertNoClientText(t, buf)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// assertBoundedFlood holds the whole flood's log output to what the
|
|
// stated per-line ceiling allows. The flood sent
|
|
// floodRequests * oversizedFillBytes bytes of client-chosen text;
|
|
// this is the assertion that the log did not grow with it.
|
|
func assertBoundedFlood(t *testing.T, got int) {
|
|
t.Helper()
|
|
|
|
sent := floodRequests * oversizedFillBytes
|
|
|
|
require.Less(
|
|
t, got, sent/2,
|
|
"log volume tracked the size of the flood's input",
|
|
)
|
|
require.LessOrEqual(
|
|
t, got,
|
|
floodRequests*middleware.MaxAccessLogLineBytes,
|
|
)
|
|
}
|