Files
webhooker/internal/handlers/auth_test.go
T
clawbot 507980a347
check / check (push) Waiting to run
Bound username length at creation (closes #184)
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
2026-10-01 21:38:48 +02:00

486 lines
12 KiB
Go

package handlers_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"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/session"
)
const (
// operatorUser and operatorPassword are the single admin account
// these tests defend.
operatorUser = "admin"
operatorPassword = "correct horse battery staple"
// sharedProxyPeer is the whole point of this file. Production is
// required to run behind a TLS-terminating reverse proxy, and
// TRUSTED_PROXIES defaults to empty, so every client — attacker
// and operator alike — reaches the process from the proxy's
// address and shares one rate-limit bucket. Both parties in
// these tests therefore use the same RemoteAddr.
sharedProxyPeer = "10.0.0.1:44444"
// loginFailureLimit is the failure budget one client has against
// one submitted username. Restated here rather than imported
// from the middleware package, so that changing the production
// limit fails these tests instead of silently moving with them.
loginFailureLimit = 5
)
// seedOperator gives the bootstrapped admin account a password these
// tests know. The account itself is created at startup with a random
// password, which is exactly why its username is predictable to an
// attacker and why keying failures by username alone does not fix
// this issue.
func seedOperator(t *testing.T, db *database.Database) {
t.Helper()
hash, err := database.HashPassword(operatorPassword)
require.NoError(t, err)
result := db.DB().Model(&database.User{}).
Where("username = ?", operatorUser).
Update("password", hash)
require.NoError(t, result.Error)
require.EqualValues(
t, 1, result.RowsAffected,
"the bootstrap admin account must exist",
)
}
// loginPost builds a login form POST arriving from peer.
func loginPost(peer, username, password string) *http.Request {
form := url.Values{}
form.Set("username", username)
form.Set("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",
)
req.RemoteAddr = peer
return req
}
// submitLogin drives one login POST through the handler.
func submitLogin(
h *handlers.Handlers, peer, username, password string,
) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
h.HandleLoginSubmit().ServeHTTP(w, loginPost(
peer, username, password,
))
return w
}
// floodFailures sends attempts wrong-password logins for username
// from peer, which is what an attacker does.
func floodFailures(
t *testing.T,
h *handlers.Handlers,
peer, username string,
attempts int,
) {
t.Helper()
for i := range attempts {
w := submitLogin(h, peer, username, fmt.Sprintf("guess-%d", i))
require.NotEqual(
t, http.StatusSeeOther, w.Code,
"attempt %d must not authenticate", i,
)
}
}
// TestLogin_StrangersFloodCannotLockOutTheOperator is the
// done-criterion of https://git.eeqj.de/sneak/webhooker/issues/150.
//
// The attacker and the operator share one rate-limit bucket, because
// behind the mandated reverse proxy with TRUSTED_PROXIES unset every
// client keys on the proxy's address. The attacker floods the
// operator's own username — a single-admin product has a predictable
// one — far past the failure limit. The operator must still be able
// to log in with the correct password.
//
// This fails if credentials stop being verified ahead of the limiter.
func TestLogin_StrangersFloodCannotLockOutTheOperator(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
// Well past the limit, and from the same bucket the operator
// will arrive in.
floodFailures(
t, h, sharedProxyPeer, operatorUser,
loginFailureLimit*2,
)
w := submitLogin(
h, sharedProxyPeer, operatorUser, operatorPassword,
)
assert.Equal(
t, http.StatusSeeOther, w.Code,
"a correct password must never be throttled: the operator "+
"has no second administrative path",
)
assert.Equal(t, "/", w.Header().Get("Location"))
}
// TestLogin_StrangersFloodCannotDenyAnotherAccount is the
// cross-account half: flooding one username must not spend another
// account's budget, even from the same shared bucket.
func TestLogin_StrangersFloodCannotDenyAnotherAccount(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
floodFailures(
t, h, sharedProxyPeer, "someone-else",
loginFailureLimit*2,
)
w := submitLogin(h, sharedProxyPeer, operatorUser, "wrong")
assert.Equal(
t, http.StatusUnauthorized, w.Code,
"a flood against one username must not spend another "+
"account's failure budget",
)
}
// TestLogin_RepeatedWrongPasswordsAreThrottled is the brute-force
// half. Verifying before counting must not remove the throttle:
// repeated wrong passwords for one username from one client key run
// out of budget and are answered 429 with a Retry-After.
func TestLogin_RepeatedWrongPasswordsAreThrottled(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
for i := range loginFailureLimit - 1 {
w := submitLogin(
h, sharedProxyPeer, operatorUser,
fmt.Sprintf("guess-%d", i),
)
assert.Equal(
t, http.StatusUnauthorized, w.Code,
"attempt %d is still inside the budget", i,
)
}
w := submitLogin(h, sharedProxyPeer, operatorUser, "guess-last")
assert.Equal(
t, http.StatusTooManyRequests, w.Code,
"wrong passwords must still run out of budget",
)
assert.NotEmpty(
t, w.Header().Get("Retry-After"),
"a throttled login must say when to come back",
)
}
// TestLogin_SuccessForgivesEarlierMistakes covers the operator who
// mistypes several times and then gets it right: the successful
// attempt clears the counter, so the next mistake is answered 401
// rather than 429.
func TestLogin_SuccessForgivesEarlierMistakes(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
floodFailures(
t, h, sharedProxyPeer, operatorUser,
loginFailureLimit,
)
require.Equal(
t, http.StatusSeeOther,
submitLogin(
h, sharedProxyPeer, operatorUser, operatorPassword,
).Code,
)
w := submitLogin(h, sharedProxyPeer, operatorUser, "typo")
assert.Equal(
t, http.StatusUnauthorized, w.Code,
"a success must forgive the failures before it",
)
}
// TestLogin_UnknownUsernameCostsTheSameVerification is the
// username-enumeration guard. Verifying credentials before the
// limiter means response time is observable per attempt, so an
// unknown username must be charged an equivalent-cost verification
// against a dummy hash rather than returning early.
//
// The assertion is on the code path, not on wall-clock time: timing
// assertions are flaky, and what actually has to hold is that the
// hash is computed.
func TestLogin_UnknownUsernameCostsTheSameVerification(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
require.Zero(t, h.DummyVerificationsForTest())
// A username that exists, with the wrong password: a real
// Argon2id verification runs, and no dummy is needed.
require.Equal(
t, http.StatusUnauthorized,
submitLogin(h, sharedProxyPeer, operatorUser, "wrong").Code,
)
assert.Zero(
t, h.DummyVerificationsForTest(),
"a known username verifies against its own hash",
)
// A username that does not exist: indistinguishable response,
// and the equivalent-cost verification must have run.
require.Equal(
t, http.StatusUnauthorized,
submitLogin(h, sharedProxyPeer, "nosuchuser", "wrong").Code,
)
assert.Equal(
t, uint64(1), h.DummyVerificationsForTest(),
"an unknown username must still pay for a hash, or the "+
"response time says whether the account exists",
)
}
// TestLogin_ConcurrentLoginsAreAllAnswered covers the login path
// under the verification bound. The bound itself is pinned in the
// middleware package; what matters here is that funnelling every
// login through two slots does not lose or wedge a request — each one
// is answered, whether it got a slot or was shed with 503.
func TestLogin_ConcurrentLoginsAreAllAnswered(t *testing.T) {
t.Parallel()
const workers = 4
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
var (
wg sync.WaitGroup
mu sync.Mutex
answers = map[int]int{}
)
for i := range workers {
wg.Go(func() {
w := submitLogin(
h, fmt.Sprintf("203.0.113.%d:5000", i),
operatorUser, fmt.Sprintf("guess-%d", i),
)
mu.Lock()
answers[w.Code]++
mu.Unlock()
})
}
wg.Wait()
mu.Lock()
defer mu.Unlock()
assert.Zero(
t, answers[http.StatusInternalServerError],
"concurrent logins must not error",
)
assert.Equal(
t, workers,
answers[http.StatusUnauthorized]+
answers[http.StatusTooManyRequests]+
answers[http.StatusServiceUnavailable],
"every concurrent login must be answered, whether it got "+
"a verification slot or was shed with 503",
)
}
// TestLogin_MissingCredentialsRejectedBeforeAnyHash pins that the
// empty-field check still runs ahead of the verification slot, so a
// client sending nothing cannot occupy one.
func TestLogin_MissingCredentialsRejectedBeforeAnyHash(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
w := submitLogin(h, sharedProxyPeer, "", "")
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Zero(
t, h.DummyVerificationsForTest(),
"an empty submission must not cost a hash",
)
}
// TestLogin_SuccessCreatesSession is the control for the tests above:
// the success path they assert on really does authenticate.
func TestLogin_SuccessCreatesSession(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
sess *session.Session
)
app := newTestApp(t, &h, &db, &sess)
app.RequireStart()
t.Cleanup(app.RequireStop)
seedOperator(t, db)
w := submitLogin(
h, sharedProxyPeer, operatorUser, operatorPassword,
)
require.Equal(t, http.StatusSeeOther, w.Code)
require.NotEmpty(
t, w.Result().Cookies(), "a session cookie must be issued",
)
next := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil,
)
// Login regenerates the session, so the response carries two
// Set-Cookie headers under the same name: one expiring the
// pre-login cookie and one issuing the new one. A browser keeps
// only the second, so replay only the one that is not an
// expiry.
for _, c := range w.Result().Cookies() {
if c.MaxAge >= 0 {
next.AddCookie(c)
}
}
s, err := sess.Get(next)
require.NoError(t, err)
assert.True(
t, sess.IsAuthenticated(s),
"the issued cookie must carry an authenticated session",
)
}
// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly
// database.MaxUsernameBytes still fits in the session cookie. Past
// what the cookie can carry, a correct login answers 500.
func TestLogin_UsernameAtLimitCanLogIn(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
db *database.Database
)
app := newTestApp(t, &h, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
username := strings.Repeat("a", database.MaxUsernameBytes)
hash, err := database.HashPassword(operatorPassword)
require.NoError(t, err)
require.NoError(t, db.DB().Create(&database.User{
Username: username,
Password: hash,
}).Error)
w := submitLogin(h, sharedProxyPeer, username, operatorPassword)
assert.Equal(t, http.StatusSeeOther, w.Code)
}