fix(auth): enumeration, verrou, deconnexion en GET, redirection ouverte

Quatre constats de la liste des gains rapides, tous sur auth.py.

SEC-017 -- enumeration de comptes
Le formulaire repondait "il vous reste 3 tentative(s)" a un compte connu et
"verifiez le nom d utilisateur et le mot de passe" a un inconnu. Le decompte
lui-meme etait la fuite : la meme information, etalee sur cinq requetes.
S y ajoutait un ecart de temps de reponse, check_password n etant appele que
si la ligne existait -- scrypt est cher, l ecart est mesurable.

Un seul message pour tous les echecs, et la verification s execute
desormais sur les deux branches : contre un hachage aleatoire tire une fois
par processus quand l identifiant n existe pas.

SEC-018 -- verrou de compte declenchable par un tiers
Cinq mauvaises reponses mettaient un compte connu hors service pendant
quinze minutes, indefiniment renouvelables. Sur un compte president, c est
toute l administration, et aucun ecran ne permettait de defaire.

Le compteur et la fenetre restent -- ce sont la trace qu un administrateur
lit quand un compte est pilonne, et la fenetre double jusqu a un plafond.
Ce qui change : de bons identifiants passent, fenetre ouverte ou non, et
remettent le compteur a zero. Le proprietaire du compte ne peut plus etre
bloque par un tiers.

Ce que cela coute, dit franchement : un verrou dur n arretait de toute
facon pas un attaquant ayant trouve le mot de passe -- il lui suffisait
d attendre. Le debit de tentatives reste borne par la limite de 10/minute
par IP. Une limite par couple (compte, IP) demanderait un stockage dedie ;
elle attend Alembic.

L evenement account.locked devient account.throttled : "locked" affirmait
plus que ce qui se passe.

SEC-019 -- deconnexion en GET
/auth/logout n avait pas de methods, donc GET, donc hors protection CSRF :
n importe quelle page pouvait deconnecter un visiteur avec une balise img.
La route passe en POST et l entree de navigation devient un formulaire avec
jeton. Le style suit -- les regles .nav-links visaient les liens seuls.

SEC-020 -- validation de redirection
is_safe_url interrogeait urlparse().netloc. urlparse lit /\evil.com comme
un chemin, sans netloc ; plusieurs navigateurs normalisent l antislash en
barre oblique avant de resoudre, ce qui en fait //evil.com. La fonction
refuse maintenant antislash et caracteres de controle, exige un chemin
enracine, et compare l origine explicitement.

Le xfail(strict) qui documentait SEC-017 est leve. 40 tests dans
test_auth_session.py, dont la table des cibles refusees.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
GGThed
2026-08-08 15:05:13 -04:00
co-authored by Claude Opus 5
parent d85da32ef5
commit 835a394d3f
11 changed files with 1245 additions and 720 deletions
+171 -6
View File
@@ -100,12 +100,44 @@ class TestLogout:
as_role('player')
assert client.get('/users/profile').status_code == 200
client.get('/auth/logout')
client.post('/auth/logout')
response = client.get('/users/profile', follow_redirects=False)
assert response.status_code in (301, 302)
assert '/auth/login' in response.headers.get('Location', '')
def test_a_get_no_longer_logs_anyone_out(self, client, as_role):
"""SEC-019 — a GET route carries no CSRF token, so any page could
sign the user out with <img src=".../auth/logout">."""
as_role('player')
assert client.get('/auth/logout').status_code == 405
assert client.get('/users/profile').status_code == 200
def test_the_logout_control_carries_a_csrf_token(self, app_with_csrf, make_user, login):
"""The nav entry is a form now; without the token it would 400 on
every user, and only in production where CSRF is on."""
client = app_with_csrf.test_client()
with app_with_csrf.app_context():
from app.extensions import hash_password
from app.models import Player
user = Player(username='navtest', password_hash=hash_password('Password123'),
role='player', full_name='Nav Test', email='[email protected]')
db.session.add(user)
db.session.commit()
page = client.get('/auth/login').get_data(as_text=True)
token = re.search(r'name="csrf_token" value="([^"]+)"', page).group(1)
client.post('/auth/login', data={'username': 'navtest',
'password': 'Password123',
'csrf_token': token})
body = client.get('/users/profile').get_data(as_text=True)
form = re.search(
r'<form method="POST" action="/auth/logout".*?</form>', body, re.S)
assert form is not None, 'no logout form in the navigation'
assert 'name="csrf_token"' in form.group(0)
class TestLoginRejection:
def test_wrong_password_is_refused(self, client, make_user, login, app):
@@ -118,11 +150,6 @@ class TestLoginRejection:
response = client.get('/users/profile', follow_redirects=False)
assert response.status_code in (301, 302)
@pytest.mark.xfail(
strict=True,
reason='SEC-AUTH-006: the two branches emit different messages, '
'which lets an unauthenticated caller enumerate accounts',
)
def test_login_failure_message_does_not_reveal_account_existence(
self, client, make_user, app
):
@@ -143,3 +170,141 @@ class TestLoginRejection:
return _flash_messages(body)
assert failure_message(username) == failure_message('no-such-account')
def test_the_message_stays_the_same_past_the_attempt_threshold(
self, client, make_user, app
):
"""The tally used to be counted out loud — "3 attempt(s) remaining"
— which is the same disclosure, spread over five requests."""
user_id = make_user('player')
with app.app_context():
username = db.session.get(User, user_id).username
seen = set()
for _ in range(7):
body = client.post(
'/auth/login',
data={'username': username, 'password': 'WrongPassword1'},
follow_redirects=True,
).get_data(as_text=True)
seen.update(_flash_messages(body))
assert len(seen) == 1, f'the message varies with the tally: {seen}'
class TestFailedAttemptThrottle:
"""SEC-018 — five wrong guesses used to take a known account out of
service for fifteen minutes, repeatably. On a president's account that
is the whole administration, and nothing in the product could undo it."""
@staticmethod
def _exhaust(client, username, times=6):
for _ in range(times):
client.post('/auth/login',
data={'username': username, 'password': 'WrongPassword1'},
follow_redirects=True)
def test_the_owner_still_gets_in_after_the_threshold(
self, app, client, make_user, login
):
user_id = make_user('player')
with app.app_context():
username = db.session.get(User, user_id).username
self._exhaust(client, username)
login(username)
assert client.get('/users/profile').status_code == 200
def test_a_successful_login_clears_the_tally(self, app, client, make_user, login):
user_id = make_user('player')
with app.app_context():
username = db.session.get(User, user_id).username
self._exhaust(client, username)
login(username)
with app.app_context():
user = db.session.get(User, user_id)
assert user.failed_login_attempts == 0
assert user.locked_until is None
def test_the_window_is_still_recorded(self, app, client, make_user):
"""It no longer gates anything, but it is the trace an administrator
reads when an account is being hammered."""
user_id = make_user('player')
with app.app_context():
username = db.session.get(User, user_id).username
self._exhaust(client, username)
with app.app_context():
user = db.session.get(User, user_id)
assert user.failed_login_attempts >= 5
assert user.locked_until is not None
def test_the_window_grows_with_repetition(self):
from app.routes.auth import MAX_LOCKOUT_MINUTES, cooloff_minutes
assert cooloff_minutes(5) == 15
assert cooloff_minutes(10) == 30
assert cooloff_minutes(15) == 60
assert cooloff_minutes(1000) == MAX_LOCKOUT_MINUTES
class TestRedirectValidation:
"""SEC-020 — is_safe_url() asked urlparse for a netloc, and urlparse
reads '/\evil.com' as a path. Several browsers normalise the backslash
to a slash first, which makes it the protocol-relative '//evil.com'."""
REFUSED = [
'//evil.example',
'/\evil.example',
'\\evil.example',
'https://evil.example/phish',
'javascript:alert(1)',
'dashboard',
'/dash\nboard',
'',
None,
]
ACCEPTED = [
'/dashboard',
'/users/profile?tab=games',
'/teams#roster',
]
@pytest.mark.parametrize('target', REFUSED)
def test_a_hostile_target_is_refused(self, app, target):
from app.routes.auth import is_safe_url
with app.test_request_context('/auth/login'):
assert not is_safe_url(target)
@pytest.mark.parametrize('target', ACCEPTED)
def test_an_internal_path_is_accepted(self, app, target):
from app.routes.auth import is_safe_url
with app.test_request_context('/auth/login'):
assert is_safe_url(target)
def test_the_same_host_spelled_out_is_accepted(self, app):
from app.routes.auth import is_safe_url
with app.test_request_context('/auth/login'):
from flask import request
assert is_safe_url(f'http://{request.host}/dashboard')
def test_the_login_redirect_refuses_to_leave_the_site(self, client, make_user, app):
user_id = make_user('player')
with app.app_context():
username = db.session.get(User, user_id).username
response = client.post(
'/auth/login?next=/%5Cevil.example',
data={'username': username, 'password': 'Password123'},
follow_redirects=False,
)
assert 'evil.example' not in response.headers.get('Location', '')