Django admin: its login signs an administrator into all of Postulo with a password alone, skipping the second factor #367

Closed
opened 2026-10-01 10:46:11 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, by reading the admin site and signing in through it in a test.

Checked again on 2026-10-01 against main at 0a5cad587: still present.

When the admin is mounted, ThrottledAdminSite.login adds a rate limit and then hands over to Django's stock admin login view. That view checks the username and password and calls auth.login, which sets the same session cookie the rest of Postulo uses. allauth's login flow is never involved, so the stages Postulo relies on for a password sign-in never run. That covers the TOTP or passkey code (allauth.mfa's AuthenticateStage) and the e-mail verification gate. An administrator who has set up a second factor is one password away from a full session.

  • Where: core/admin_site.py:34-49 (ThrottledAdminSite.login, ending in return super().login(request, extra_context)); config/urls.py:33-36 (mounted when POSTULO_ADMIN_URL is set). allauth.account.decorators.secure_admin_login, which allauth ships for this purpose, is used nowhere in src or tests.
  • What happens: checked in a test against this snapshot, with the admin mounted and a superuser who has a verified address and TOTP turned on:
    • posting the password to /accounts/login/ redirects to /accounts/2fa/authenticate/, as it should;
    • posting the same password to <admin path>login/ redirects to the admin index with an authenticated session;
    • /applications/ then answers 200, and so does every other page, with no code asked for.
  • Who can trigger it: anyone who holds an administrator's password, on an instance whose operator has mounted the admin. The admin is off by default (#116).
  • Why it matters: the second factor is there for exactly the case of a phished or reused password. Here it guards the front door only. The wiki's Hardening page, line 265, says the opposite: "somebody with an authenticator app who signs in with a password is asked for the code, always". The account reached is an administrator's, which with the admin mounted reaches every table.
  • Fix: send the admin's login through allauth. In ThrottledAdminSite, wrap the view with allauth's decorator, secure_admin_login(super().login), or redirect to account_login with next set to the admin index. An unauthenticated visitor is then sent to Postulo's own sign-in, which already carries the rate limit and every stage, and the admin's own throttle becomes redundant. Say in the wiki's Hardening page that the admin signs in through the ordinary sign-in.

Done when

  • A test in tests/security/test_admin_exposure.py mounts the admin, gives a staff account a verified address and an active TOTP authenticator, and posts the right password to the admin's login. It asserts that no authenticated session results, and that the response leads to Postulo's sign-in or second-factor step. Today the session is created.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, by reading the admin site and signing in through it in a test. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. When the admin is mounted, `ThrottledAdminSite.login` adds a rate limit and then hands over to Django's stock admin login view. That view checks the username and password and calls `auth.login`, which sets the same session cookie the rest of Postulo uses. allauth's login flow is never involved, so the stages Postulo relies on for a password sign-in never run. That covers the TOTP or passkey code (`allauth.mfa`'s `AuthenticateStage`) and the e-mail verification gate. An administrator who has set up a second factor is one password away from a full session. - **Where:** `core/admin_site.py:34-49` (`ThrottledAdminSite.login`, ending in `return super().login(request, extra_context)`); `config/urls.py:33-36` (mounted when `POSTULO_ADMIN_URL` is set). `allauth.account.decorators.secure_admin_login`, which allauth ships for this purpose, is used nowhere in `src` or `tests`. - **What happens:** checked in a test against this snapshot, with the admin mounted and a superuser who has a verified address and TOTP turned on: - posting the password to `/accounts/login/` redirects to `/accounts/2fa/authenticate/`, as it should; - posting the same password to `<admin path>login/` redirects to the admin index with an authenticated session; - `/applications/` then answers 200, and so does every other page, with no code asked for. - **Who can trigger it:** anyone who holds an administrator's password, on an instance whose operator has mounted the admin. The admin is off by default (#116). - **Why it matters:** the second factor is there for exactly the case of a phished or reused password. Here it guards the front door only. The wiki's *Hardening* page, line 265, says the opposite: "somebody with an authenticator app who signs in with a password is asked for the code, always". The account reached is an administrator's, which with the admin mounted reaches every table. - **Fix:** send the admin's login through allauth. In `ThrottledAdminSite`, wrap the view with allauth's decorator, `secure_admin_login(super().login)`, or redirect to `account_login` with `next` set to the admin index. An unauthenticated visitor is then sent to Postulo's own sign-in, which already carries the rate limit and every stage, and the admin's own throttle becomes redundant. Say in the wiki's *Hardening* page that the admin signs in through the ordinary sign-in. ## Done when - A test in `tests/security/test_admin_exposure.py` mounts the admin, gives a staff account a verified address and an active TOTP authenticator, and posts the right password to the admin's login. It asserts that no authenticated session results, and that the response leads to Postulo's sign-in or second-factor step. Today the session is created.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:46:11 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#367
No description provided.