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:
+171
-6
@@ -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', '')
|
||||
|
||||
Reference in New Issue
Block a user