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", ) }