Feat | Harden login captcha gate by replacing request body counter with server-side session counter#132
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
1697b7a to
b4787a5
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
b4787a5 to
5fc57eb
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
5fc57eb to
8cf7686
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
8cf7686 to
4e46889
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
4e46889 to
26f2088
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
fcfcf70 to
75cabac
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
75cabac to
8f00f9c
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
8f00f9c to
806f580
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
1 similar comment
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
806f580 to
1babb4a
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
1babb4a to
f45779e
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
f45779e to
042038b
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
042038b to
5c16592
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
5c16592 to
00e6908
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
…h server-side session counter (UserController::postLogin)
00e6908 to
6a12766
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-132/ This page is automatically updated on each push to this PR. |
There was a problem hiding this comment.
Change request — race condition leaves Task 2 / AC #2 unmet under concurrency
The PR satisfies Task 2's "+1 per failure" invariant and AC #2's threshold guarantee in sequential request flows, but both break under concurrent POSTs against the same session.
Root cause
- Deployed session driver is redis (
SESSION_DRIVER=redisin.env.example:27) Illuminate\Session\CacheBasedSessionHandler::write()overwrites the entire serialized session blob non-atomically (no read-modify-write lock)Illuminate\Session\SessionManager::shouldBlock()defaults tofalseand the login POST route is not blocked (routes/web.php:52)
Two concurrent failed POSTs sharing the same session cookie both read counter=0, both compute 0+1=1, both write counter=1 — the second increment is lost. An attacker firing N concurrent POSTs can make N failed attempts while the captcha gate sees counter=1, holding the gate open through the entire burst. That contradicts:
- Task 2 ("Increment session counter on every authentication failure")
- AC #2 (counter at threshold must reliably trigger the
cf-turnstile-responserule) - Goal ("the validator gates the captcha rule on a counter the attacker cannot tamper with")
Suggested patch
--- a/routes/web.php
+++ b/routes/web.php
@@ -49,7 +49,7 @@
Route::group(array('prefix' => 'verification'), function () {
Route::post('resend', ['middleware' => ['csrf'], 'uses' => 'UserController@resendVerificationEmail']);
});
- Route::post('', ['middleware' => 'csrf', 'uses' => 'UserController@postLogin']);
+ Route::post('', ['middleware' => 'csrf', 'uses' => 'UserController@postLogin'])->block();Route::block() serializes concurrent same-session requests via an atomic cache lock. Defaults (10s lock TTL, 10s wait) are appropriate for an auth endpoint — tune if needed.
Test coverage note
The +1 per sequential failure invariant is already pinned by the existing testRepeatedKnownUserFailuresIncrementSessionCounterByOnePerAttempt at tests/UserLoginTurnstileTest.php:340, which asserts counter === CAPTCHA_THRESHOLD after sequential failures. BrowserKit is single-threaded so it can't reproduce the race itself — no new test is required to land this change. The route block alone closes the gap.
With the route block in place, the ticket's tasks, acceptance criteria, and goal are all satisfied.
|
Thanks @caseylocker for your comment, I added the requested change. Please review.
|
Task
Ref.: https://app.clickup.com/t/86b9v1acw
Dependency
Ref: https://app.clickup.com/t/86b8xw6ue (Migrate from Google reCAPTCHA to Cloudflare Turnstile)
PR #121 - Feat | Migrate from Google reCAPTCHA to Cloudflare Turnstile
Changes
The change fixes a security gap where the captcha trigger counter was controlled by the client (sent in the request body) instead of the server. Three files changed:
app/Http/Controllers/UserController.php$login_attemptsis now initialized from the server-side session (Session::get('captcha_failed_attempts', 0)) instead of from the request body.$user->updateLoginFailedAttempt()and persisted to the session.Session::forget). The user lookup was moved before the auth attempt so it's available in both success and failure paths.login_attemptsfrom the session instead of the request body, preventing clients from resetting or falsifying the counter.resources/js/login/login.jslogin_attemptsin the request body (no longer needed/meaningful).tests/UserLoginTurnstileTest.phpRequested GOAL
Current state
After rolling back the username-enumeration regression from PR #121, UserController::postLogin() reads $login_attempts from Request::input('login_attempts') (the original implementation). The value travels through a hidden form field that errorLogin() flashes into the session and login.blade.php renders into the next form. This source is server-set under the normal browser flow, but it is supplied in the request body and is therefore fully attacker-controllable: a curl POST that omits the field, or sets login_attempts=0, skips the captcha rule unconditionally regardless of how many times the attacker has failed. The captcha gate is effectively a suggestion, not a control.
Target state
$login_attempts is sourced from a Laravel session value that the server alone writes. The validator gates the captcha rule on a counter the attacker cannot tamper with from the request body,
while still being independent of the submitted username (so the gate does not fork on user existence and the enumeration oracle that PR #121 introduced cannot reappear). The persisted per-user
counter (User.loginFailedAttempt) remains in place for audit and lockout, but does not drive the captcha decision.
TASKS
1.1 Change $login_attempts = intval(Request::input('login_attempts')) to $login_attempts = (int) Session::get('captcha_failed_attempts', 0).
1.2 Confirm the validator rule block (cf-turnstile-response = ['required', new Turnstile()] when $login_attempts >= threshold) is unchanged.
2.1 In the AuthenticationException catch in postLogin (around L479-L501) increment Session::put('captcha_failed_attempts', current+1) before calling errorLogin().
2.2 Pass the updated session counter as login_attempts in the errorLogin payload so the redirected login page renders the right config.
3.1 Locate the successful authentication path (login_strategy->postLogin() or equivalent) and call Session::forget('captcha_failed_attempts') after the authenticated session is established.
4.1 Remove or stop rendering the hidden login_attempts input in login.blade.php (or wherever it is emitted). Server-driven gating no longer needs it.
4.2 Confirm the React login.js no longer reads it, or treats absence as 0.
5.1 Replace any test setup that relied on Request::input('login_attempts') (overrides like ['login_attempts' => N]) with Session::put('captcha_failed_attempts', N) via withSession() or
equivalent.
5.2 Add testRequestSuppliedLoginAttemptsIsIgnored: post username + password + login_attempts=0 with the session counter at threshold, assert cf-turnstile-response is still required (proves the
request-body field is no longer trusted).
5.3 Add testCaptchaRequiredForUnknownUsernameWhenSessionAtThreshold: with the session counter at threshold, post a non-existent username and no captcha; assert cf-turnstile-response is required
(locks in enumeration-safety alongside the rollback).
5.4 Add testSuccessfulLoginClearsSessionCounter: drive counter to threshold, post valid credentials with a faked Turnstile pass, assert Session::has('captcha_failed_attempts') is false
afterwards.
5.5 Update testLoginScreenIncludesTurnstileConfigWhenAboveThreshold to drive the precondition via Session::put('captcha_failed_attempts', threshold - 1) instead of any request-body or DB-mutation helper.
ACCEPTANCE CRITERIA
counter at threshold, return validator error sets that both contain cf-turnstile-response (no enumeration oracle).
threshold; using curl, attempt to bypass with login_attempts=0 in the body and confirm Cloudflare validation still fails.
Summary by CodeRabbit