All checks were successful
check / check (push) Successful in 2m54s
With TRUSTED_PROXIES empty behind the reverse proxy production is required to run behind, every login POST keyed on the proxy's address and shared one 5/minute bucket. A stranger sending five POSTs a minute -- 0.08 requests per second, from anywhere -- kept that bucket permanently full, and the operator's own correct password was answered 429 indefinitely with no second administrative path. The login POST no longer has a pre-emptive limiter. The handler verifies credentials first and spends budget only on a FAILED attempt, so a correct password is never throttled whatever the counters hold. Three things follow, and are implemented together because the first is unsafe without the other two: - Failures are counted per (client bucket, submitted username), five per minute, after which further failures get 429 with a Retry-After. A successful login clears the counter, so mistyping and then succeeding does not leave the operator throttled. - Both key sets are capped at 1024 entries. The submitted username is attacker-controlled, so past the first cap failures fall back to a counter keyed on the client alone, and past both caps a failure is answered as throttled without being recorded. Tracked state stays under half a megabyte and does not grow with invented usernames. - Concurrent Argon2id verifications are capped at two, a 128 MB ceiling at 64 MB per hash. Every password-hashing endpoint takes a slot, including the password-change endpoint, which holds one across both its hashes. A request that waits five seconds without a slot is answered 503 and no hash runs for it. An unknown username is verified against a dummy hash instead of returning early, so a nonexistent account costs the same time as a real one and the response cannot be used to enumerate usernames. The password-change limiter is unchanged: RequireAuth runs ahead of it, so only a request already carrying a valid session reaches its bucket. Also adds the missing test for the third bucketKey call site, where the peer is a trusted proxy but the forwarded chain names no client. Every existing test of that fallback uses an IPv4 proxy, where bucketKey is the identity function, so dropping the /64 masking there left the suite green. README and the TRUSTED_PROXIES startup warning updated: a shared bucket now costs precision, not the availability of the admin path.
456 lines
11 KiB
Go
456 lines
11 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",
|
|
)
|
|
}
|