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
+95 -43
View File
@@ -24,9 +24,17 @@ import requests
#: Session key holding the pending OAuth2 anti-forgery token. #: Session key holding the pending OAuth2 anti-forgery token.
DISCORD_STATE_KEY = 'discord_oauth_state' DISCORD_STATE_KEY = 'discord_oauth_state'
# Account lockout settings # Failed-attempt tracking. The tally is kept for the audit trail and for the
# cool-off marker below; it no longer refuses a correct password (SEC-018).
MAX_LOGIN_ATTEMPTS = 5 MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15 LOCKOUT_DURATION_MINUTES = 15
#: Ceiling on the doubling cool-off window.
MAX_LOCKOUT_MINUTES = 240
#: Hash of a value nobody can submit. Verifying against it when the username
#: is unknown makes that path cost the same scrypt work as a real one, so the
#: response time stops telling a caller which usernames exist (SEC-017).
_ABSENT_USER_HASH = None
# Discord OAuth2 configuration # Discord OAuth2 configuration
DISCORD_CLIENT_ID = os.getenv('DISCORD_CLIENT_ID') DISCORD_CLIENT_ID = os.getenv('DISCORD_CLIENT_ID')
@@ -47,17 +55,64 @@ DISCORD_PLATFORM_TO_GAMES = {
def is_safe_url(url): def is_safe_url(url):
"""Validate that a URL is safe for redirection (same origin). """Validate that a URL is safe for redirection (same origin).
Accepts an absolute URL on this host, or a path beginning with exactly
one slash. Everything else is refused, including the two forms that
read differently to urlparse and to a browser:
/\\evil.com several browsers normalise the backslash to a slash,
turning this into the protocol-relative //evil.com.
urlparse reports no netloc at all, so the old check
let it through and the redirect left the site.
/\\n//evil.com control characters are stripped before parsing.
Args: Args:
url: The URL to validate. url: The URL to validate.
Returns: Returns:
bool: True if the URL is safe (relative or same origin). bool: True if the URL is safe.
""" """
if not url: if not url:
return False return False
if any(ord(char) < 0x20 or char in '\\\x7f' for char in url):
return False
parsed = urlparse(url) parsed = urlparse(url)
# Allow relative URLs (no netloc) or same-origin URLs if parsed.netloc:
return not parsed.netloc or parsed.netloc == request.host return (parsed.netloc == request.host
and parsed.scheme in ('', 'http', 'https'))
# Relative targets must be rooted. 'dashboard' or 'javascript:...' are
# not paths on this site.
return url.startswith('/')
def _absent_user_hash():
"""A hash to verify against when the submitted username does not exist.
check_password() used to be reached only when a user row was found, so
an unknown username answered as fast as the database lookup, and a known
one as slowly as scrypt. The gap is measurable and enumerates accounts.
Computed once per process, from a random secret, so no submitted password
can ever match it.
"""
global _ABSENT_USER_HASH
if _ABSENT_USER_HASH is None:
_ABSENT_USER_HASH = hash_password(secrets.token_urlsafe(32))
return _ABSENT_USER_HASH
def cooloff_minutes(failed_attempts):
"""Length of the cool-off window earned by this many failed attempts.
Doubles every MAX_LOGIN_ATTEMPTS further failures, up to a ceiling.
Args:
failed_attempts: Consecutive failures recorded on the account.
Returns:
int: Minutes.
"""
steps = max(failed_attempts // MAX_LOGIN_ATTEMPTS - 1, 0)
return min(LOCKOUT_DURATION_MINUTES * (2 ** steps), MAX_LOCKOUT_MINUTES)
def generate_captcha(): def generate_captcha():
@@ -102,16 +157,17 @@ auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
@auth_bp.route('/login', methods=['GET', 'POST']) @auth_bp.route('/login', methods=['GET', 'POST'])
@limiter.limit("10 per minute") @limiter.limit("10 per minute")
def login(): def login():
"""Handle user login authentication with account lockout protection. """Handle user login authentication.
GET: Render the login form. GET: Render the login form.
POST: Authenticate user credentials with lockout check and audit logging. POST: Authenticate user credentials, with audit logging.
Account lockout: After 5 consecutive failed attempts, the account is Failed attempts are counted and open a cool-off window, recorded in
locked for 15 minutes. Successful login resets the counter. ``locked_until`` and in the authentication log. The window does not
refuse correct credentials: when it did, five wrong guesses against a
Redirects authenticated users to dashboard. Validates credentials and checks known username took that account out of service for fifteen minutes,
account status before login. repeatably, and on a president's account that meant no administration
at all. Guess rate is bounded by the rate limit on this view.
Returns: Returns:
Response: Login form or redirect to dashboard/next page. Response: Login form or redirect to dashboard/next page.
@@ -134,25 +190,23 @@ def login():
password = validated['password'] password = validated['password']
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first()
# Check if account is locked # Verified before anything else is decided, and on both branches.
if user and user.locked_until and user.locked_until > datetime.utcnow(): # Reaching this only when a row exists made the response time a
log_auth_event('login.rejected.locked', username=username, user_id=user.id) # reliable oracle for which usernames are registered (SEC-017).
remaining = (user.locked_until - datetime.utcnow()).seconds // 60 credentials_ok = check_password(
flash( user.password_hash if user else _absent_user_hash(), password
_('Account is locked due to too many failed attempts. ' )
'Please try again in %(remaining)s minute(s).', remaining=remaining),
'danger'
)
return render_template('pages/login.html')
if user and check_password(user.password_hash, password): if user and credentials_ok:
if not user.is_active_account: if not user.is_active_account:
log_auth_event('login.rejected.deactivated', log_auth_event('login.rejected.deactivated',
username=username, user_id=user.id) username=username, user_id=user.id)
flash(_('This account has been deactivated.'), 'danger') flash(_('This account has been deactivated.'), 'danger')
return render_template('pages/login.html') return render_template('pages/login.html')
# Reset failed login attempts on successful login # Correct credentials clear the tally, cool-off window included.
# The window used to refuse them too, which is what turned it
# into a way to lock a known account out at will (SEC-018).
user.failed_login_attempts = 0 user.failed_login_attempts = 0
user.locked_until = None user.locked_until = None
db.session.commit() db.session.commit()
@@ -188,33 +242,26 @@ def login():
flash(_('Welcome back, %(username)s!', username=user.username), 'success') flash(_('Welcome back, %(username)s!', username=user.username), 'success')
return redirect(next_page) if next_page else redirect(url_for('main.dashboard')) return redirect(next_page) if next_page else redirect(url_for('main.dashboard'))
else: else:
# Track failed login attempt # One message for every failure. The old code said "N attempts
# remaining" to a real account and "check username and password"
# to an unknown one, which listed the club's accounts to anyone
# who asked (SEC-017).
if user: if user:
user.failed_login_attempts += 1 user.failed_login_attempts += 1
log_auth_event('login.failure', username=username, user_id=user.id, log_auth_event('login.failure', username=username, user_id=user.id,
attempts=user.failed_login_attempts) attempts=user.failed_login_attempts)
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS: if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
user.locked_until = datetime.utcnow() + timedelta(minutes=LOCKOUT_DURATION_MINUTES) minutes = cooloff_minutes(user.failed_login_attempts)
log_auth_event('account.locked', username=username, user_id=user.id, user.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
minutes=LOCKOUT_DURATION_MINUTES) log_auth_event('account.throttled', username=username,
flash( user_id=user.id, minutes=minutes,
_('Account locked after %(attempts)s failed attempts. ' attempts=user.failed_login_attempts)
'Please try again in %(minutes)s minutes.',
attempts=MAX_LOGIN_ATTEMPTS,
minutes=LOCKOUT_DURATION_MINUTES),
'danger'
)
else:
remaining = MAX_LOGIN_ATTEMPTS - user.failed_login_attempts
flash(
_('Login unsuccessful. %(remaining)s attempt(s) remaining '
'before lockout.', remaining=remaining),
'danger'
)
db.session.commit() db.session.commit()
else: else:
log_auth_event('login.failure.unknown_user', username=username) log_auth_event('login.failure.unknown_user', username=username)
flash(_('Login unsuccessful. Please check username and password.'), 'danger')
flash(_('Login unsuccessful. Please check your username and '
'password, or ask a president for help.'), 'danger')
return render_template('pages/login.html') return render_template('pages/login.html')
@@ -513,11 +560,16 @@ def discord_callback():
return redirect(url_for('auth.register')) return redirect(url_for('auth.register'))
@auth_bp.route('/logout') @auth_bp.route('/logout', methods=['POST'])
@login_required @login_required
def logout(): def logout():
"""Log out the current user and clear the session. """Log out the current user and clear the session.
POST, not GET: a GET route is not covered by CSRF protection, so any
page on the internet could sign a user out with an <img> tag pointing
here. A nuisance rather than a compromise, but it costs one form to
close (SEC-019).
Clears the user session and regenerates session ID to prevent Clears the user session and regenerates session ID to prevent
session fixation/replay after logout. session fixation/replay after logout.
+17 -3
View File
@@ -138,7 +138,10 @@ a:hover { color: var(--primary-dark); }
padding: 10px 0; padding: 10px 0;
} }
.nav-links li a { /* Logging out is a POST, so its nav entry is a button inside a form
rather than a link. It has to read as one of the entries above it. */
.nav-links li a,
.nav-links li .nav-form button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@@ -148,7 +151,17 @@ a:hover { color: var(--primary-dark); }
font-size: 0.9rem; font-size: 0.9rem;
} }
.nav-links li a:hover, .nav-links li a.active { .nav-links li .nav-form button {
width: 100%;
background: none;
border: 0;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.nav-links li a:hover, .nav-links li a.active,
.nav-links li .nav-form button:hover {
background: rgba(255,255,255,0.08); background: rgba(255,255,255,0.08);
color: white; color: white;
} }
@@ -158,7 +171,8 @@ a:hover { color: var(--primary-dark); }
padding-left: 17px; padding-left: 17px;
} }
.nav-links li a i { width: 20px; text-align: center; font-size: 1.1rem; } .nav-links li a i,
.nav-links li .nav-form button i { width: 20px; text-align: center; font-size: 1.1rem; }
.nav-divider { .nav-divider {
height: 1px; height: 1px;
+10 -4
View File
@@ -112,10 +112,16 @@
</a> </a>
</li> </li>
<li> <li>
<a href="{{ url_for('auth.logout') }}" class="logout-link"> {# A form, not a link: logging out is a state change, and a
<i class="fas fa-sign-out-alt"></i> GET route carries no CSRF token — any site could sign the
<span>{{ _('Logout') }}</span> user out with an <img> tag. Styled as a nav entry. #}
</a> <form method="POST" action="{{ url_for('auth.logout') }}" class="nav-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="logout-link">
<i class="fas fa-sign-out-alt"></i>
<span>{{ _('Logout') }}</span>
</button>
</form>
</li> </li>
<li class="nav-divider"></li> <li class="nav-divider"></li>
<li class="nav-language"> <li class="nav-language">
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -70,7 +70,9 @@ class TestLoginEvents:
login('no-such-account', password='WrongPassword1') login('no-such-account', password='WrongPassword1')
assert _has_event(auth_log, 'login.failure.unknown_user') assert _has_event(auth_log, 'login.failure.unknown_user')
def test_a_lockout_is_recorded(self, app, client, make_user, login, auth_log): def test_a_run_of_failures_is_recorded(self, app, client, make_user, login, auth_log):
"""Renamed from account.locked with SEC-018: the window no longer
refuses the account owner, so 'locked' overstated what happened."""
user_id = make_user('player') user_id = make_user('player')
with app.app_context(): with app.app_context():
username = db.session.get(User, user_id).username username = db.session.get(User, user_id).username
@@ -78,7 +80,7 @@ class TestLoginEvents:
for _ in range(5): for _ in range(5):
login(username, password='WrongPassword1') login(username, password='WrongPassword1')
assert _has_event(auth_log, 'account.locked') assert _has_event(auth_log, 'account.throttled')
def test_a_deactivated_account_attempt_is_recorded( def test_a_deactivated_account_attempt_is_recorded(
self, app, client, make_user, login, auth_log self, app, client, make_user, login, auth_log
@@ -96,7 +98,7 @@ class TestLoginEvents:
def test_logout_is_recorded(self, client, as_role, auth_log): def test_logout_is_recorded(self, client, as_role, auth_log):
as_role('player') as_role('player')
client.get('/auth/logout') client.post('/auth/logout')
assert _has_event(auth_log, 'logout') assert _has_event(auth_log, 'logout')
+171 -6
View File
@@ -100,12 +100,44 @@ class TestLogout:
as_role('player') as_role('player')
assert client.get('/users/profile').status_code == 200 assert client.get('/users/profile').status_code == 200
client.get('/auth/logout') client.post('/auth/logout')
response = client.get('/users/profile', follow_redirects=False) response = client.get('/users/profile', follow_redirects=False)
assert response.status_code in (301, 302) assert response.status_code in (301, 302)
assert '/auth/login' in response.headers.get('Location', '') 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: class TestLoginRejection:
def test_wrong_password_is_refused(self, client, make_user, login, app): 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) response = client.get('/users/profile', follow_redirects=False)
assert response.status_code in (301, 302) 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( def test_login_failure_message_does_not_reveal_account_existence(
self, client, make_user, app self, client, make_user, app
): ):
@@ -143,3 +170,141 @@ class TestLoginRejection:
return _flash_messages(body) return _flash_messages(body)
assert failure_message(username) == failure_message('no-such-account') 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', '')
+1 -1
View File
@@ -222,7 +222,7 @@ class TestLocaleSurvivesSessionRotation:
def test_the_choice_survives_logging_out(self, client, as_role): def test_the_choice_survives_logging_out(self, client, as_role):
client.get('/lang/en') client.get('/lang/en')
as_role('player') as_role('player')
client.get('/auth/logout') client.post('/auth/logout')
body = client.get('/auth/login').get_data(as_text=True) body = client.get('/auth/login').get_data(as_text=True)
assert 'lang="en"' in body assert 'lang="en"' in body
+1 -1
View File
@@ -55,7 +55,7 @@ class TestPolymorphicIdentity:
with app.app_context(): with app.app_context():
username = db.session.get(User, target_id).username username = db.session.get(User, target_id).username
client.get('/auth/logout') client.post('/auth/logout')
login(username) login(username)
# Coach-only, and it is a coach's own page rather than a redirect. # Coach-only, and it is a coach's own page rather than a redirect.