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]>
311 lines
11 KiB
Python
311 lines
11 KiB
Python
"""Session lifecycle and account state.
|
|
|
|
These lock in the two fixes from wave 0: sessions now actually expire, and
|
|
deactivating an account now closes the sessions it already holds.
|
|
"""
|
|
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from app.extensions import db
|
|
from app.models import User
|
|
|
|
#: Matches the flash markup emitted by layouts/base.html.
|
|
_FLASH_PATTERN = re.compile(
|
|
r'<div class="alert alert-\w+ alert-dismissible">\s*<span>(.*?)</span>',
|
|
re.S,
|
|
)
|
|
|
|
|
|
def _flash_messages(body):
|
|
"""Flash texts rendered in a response, independent of the locale."""
|
|
return [m.strip() for m in _FLASH_PATTERN.findall(body)]
|
|
|
|
|
|
def _set_active(app, user_id, active):
|
|
with app.app_context():
|
|
user = db.session.get(User, user_id)
|
|
user.is_active_account = active
|
|
db.session.commit()
|
|
|
|
|
|
class TestSessionExpiry:
|
|
def test_login_issues_an_expiring_session_cookie(self, app, client, as_role):
|
|
"""PERMANENT_SESSION_LIFETIME only applies to permanent sessions.
|
|
|
|
Before the fix, session.permanent was never set anywhere in app/,
|
|
so Flask emitted a browser-session cookie with no Expires attribute
|
|
and the configured one-hour lifetime was silently ignored.
|
|
"""
|
|
as_role('player')
|
|
|
|
cookie = client.get_cookie('session')
|
|
assert cookie is not None, 'no session cookie was issued at login'
|
|
assert cookie.expires is not None, (
|
|
'session cookie has no expiry: session.permanent was not set, '
|
|
'so PERMANENT_SESSION_LIFETIME has no effect'
|
|
)
|
|
|
|
def test_session_lifetime_matches_configuration(self, app):
|
|
from datetime import timedelta
|
|
|
|
assert app.permanent_session_lifetime == timedelta(seconds=3600)
|
|
|
|
|
|
class TestAccountDeactivation:
|
|
def test_deactivated_account_cannot_log_in(self, app, client, make_user, login):
|
|
user_id = make_user('player')
|
|
_set_active(app, user_id, False)
|
|
|
|
with app.app_context():
|
|
username = db.session.get(User, user_id).username
|
|
|
|
login(username)
|
|
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_deactivated_account_loses_its_existing_session(self, app, client, as_role):
|
|
"""The fix that matters: revocation has to reach live sessions.
|
|
|
|
is_active_account used to be consulted only at login. User did not
|
|
override UserMixin.is_active, so Flask-Login treated every account
|
|
as active, and disabling someone merely stopped them reconnecting —
|
|
their open session kept working.
|
|
"""
|
|
user_id = as_role('player')
|
|
|
|
assert client.get('/users/profile').status_code == 200
|
|
|
|
_set_active(app, user_id, False)
|
|
|
|
response = client.get('/users/profile', follow_redirects=False)
|
|
assert response.status_code in (301, 302), (
|
|
'a deactivated account kept access with its existing session'
|
|
)
|
|
assert '/auth/login' in response.headers.get('Location', '')
|
|
|
|
def test_is_active_property_tracks_the_column(self, app, make_user):
|
|
user_id = make_user('coach')
|
|
with app.app_context():
|
|
user = db.session.get(User, user_id)
|
|
assert user.is_active is True
|
|
user.is_active_account = False
|
|
assert user.is_active is False
|
|
|
|
|
|
class TestLogout:
|
|
def test_logout_ends_the_session(self, client, as_role):
|
|
as_role('player')
|
|
assert client.get('/users/profile').status_code == 200
|
|
|
|
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):
|
|
user_id = make_user('player')
|
|
with app.app_context():
|
|
username = db.session.get(User, user_id).username
|
|
|
|
login(username, password='WrongPassword1')
|
|
|
|
response = client.get('/users/profile', follow_redirects=False)
|
|
assert response.status_code in (301, 302)
|
|
|
|
def test_login_failure_message_does_not_reveal_account_existence(
|
|
self, client, make_user, app
|
|
):
|
|
"""Compares the rendered flash messages rather than looking for a
|
|
known substring: the site is served in French by default, so an
|
|
English marker would silently match nothing on both sides and make
|
|
the test pass while the flaw is still there."""
|
|
user_id = make_user('player')
|
|
with app.app_context():
|
|
username = db.session.get(User, user_id).username
|
|
|
|
def failure_message(name):
|
|
body = client.post(
|
|
'/auth/login',
|
|
data={'username': name, 'password': 'WrongPassword1'},
|
|
follow_redirects=True,
|
|
).get_data(as_text=True)
|
|
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', '')
|