SEC-AUTH-008. L addition a deux operandes entre 1 et 10 avait dix-neuf reponses possibles et se resolvait en lisant la question comme une chaine. Elle n arretait aucune inscription automatisee. Elle ajoutait en revanche une etape a chaque personne, lecteur d ecran compris, contre une apparence de protection — ce qui est pire que rien, puisque ca se compte comme une protection. L autre branche proposee par l audit etait un vrai service de CAPTCHA : un tiers, une cle d API, une requete a chaque affichage, et un script etranger remis dans script-src, defaisant le travail qui a ferme SEC-WEB-001. Disproportionne pour le site d un club. A la place, deux verifications invisibles pour un visiteur : un champ piege, cache par la feuille de style et hors du parcours clavier, qu un robot remplisseur complete et qu une personne ne voit jamais ; et un delai minimal entre la remise du formulaire et son retour, l horodatage etant dans la session signee et non dans un champ. Le plafond est dit dans le code plutot que sous-entendu : ceci arrete le pourriel de masse, pas quelqu un qui lit la page. Ce qui filtrerait vraiment les inscriptions serait l activation des comptes par le staff — is_active_account vaut True par defaut. C est une decision de produit. L angle « session forgeable » du constat tombe : avec SECRET_KEY compromise (SEC-001), on forge une session connectee sur n importe quel compte et on n a aucune raison de s inscrire. Au passage, ARCH-005 en partie : le bloc « regenerer, purger les mots de passe, re-rendre » etait recopie quatre fois. Un seul helper, et la purge des mots de passe ne peut plus etre oubliee dans la cinquieme copie. Les refus sont journalises avec leur motif — c est le seul endroit ou un abus du formulaire devient visible — mais restent indistinguables pour l expediteur : nommer la regle indique comment la contourner. 429 tests.
119 lines
4.4 KiB
Python
119 lines
4.4 KiB
Python
"""What survives a failure mid-request — ARCH-006.
|
|
|
|
The audit counted 58 `db.session.commit()` in the routes against a single
|
|
`db.session.rollback()` in the whole repository, and read that ratio as a
|
|
problem. Measured rather than assumed, it mostly is not one:
|
|
|
|
- Flask-SQLAlchemy tears the session down at the end of every request,
|
|
which rolls back anything uncommitted;
|
|
- the one rollback is in the 500 handler, which is where it belongs;
|
|
- after the wave-D work, no route module contains `except Exception` at
|
|
all, so nothing swallows a failure and carries on.
|
|
|
|
What the count could not see is the real defect: a route that commits
|
|
*twice*, so a failure after the first one leaves half an operation
|
|
persisted. There were two such functions. `edit_user` was the serious one
|
|
and was fixed with ARCH-008; `register` is fixed here.
|
|
|
|
These tests state the guarantee, so that removing the rollback or
|
|
reintroducing a mid-operation commit fails loudly.
|
|
"""
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from app.models import User, UserGamertag
|
|
|
|
|
|
class TestNothingPartialSurvives:
|
|
def test_a_failure_after_add_leaves_no_row(self, app, client, as_role, monkeypatch):
|
|
"""The session is torn down per request; an uncommitted add is gone."""
|
|
as_role('admin')
|
|
|
|
from app.routes.users import accounts
|
|
|
|
def _explode(*args, **kwargs):
|
|
raise RuntimeError('storage unavailable')
|
|
|
|
monkeypatch.setattr(accounts.db.session, 'commit', _explode)
|
|
|
|
with pytest.raises(RuntimeError):
|
|
client.post(
|
|
'/users/create',
|
|
data={
|
|
'username': 'ghost',
|
|
'email': '[email protected]',
|
|
'password': 'Password123',
|
|
'full_name': 'Ghost Account',
|
|
'role': 'coach',
|
|
},
|
|
)
|
|
|
|
with app.app_context():
|
|
assert User.query.filter_by(username='ghost').first() is None
|
|
|
|
def test_the_error_handler_still_rolls_back(self, app):
|
|
"""The single rollback in the repository is the one in the 500
|
|
handler. Removing it should break something visible."""
|
|
import inspect
|
|
|
|
from app import app as app_module
|
|
|
|
source = inspect.getsource(app_module.create_app)
|
|
assert 'db.session.rollback()' in source
|
|
|
|
|
|
class TestRegistrationIsOneOperation:
|
|
"""register() committed the account, then committed the gamertags. A
|
|
failure in between left an account whose declared games were absent,
|
|
with the sign-up reported as successful."""
|
|
|
|
FORM = {
|
|
'username': 'newplayer',
|
|
'email': '[email protected]',
|
|
'password': 'Password123',
|
|
'confirm_password': 'Password123',
|
|
'full_name': 'New Player',
|
|
'games': 'Valorant',
|
|
'gamertag_Valorant': 'newplayer#1234',
|
|
}
|
|
|
|
def _submit(self, client, app, **overrides):
|
|
# Registration is screened for robots (SEC-AUTH-008): the form has to
|
|
# have been issued, and long enough ago. Backdated here rather than
|
|
# slept through, so the suite does not pay three seconds per call.
|
|
from app.routes.auth import MIN_REGISTRATION_SECONDS, REGISTRATION_ISSUED_KEY
|
|
|
|
with client.session_transaction() as session:
|
|
session[REGISTRATION_ISSUED_KEY] = time.time() - MIN_REGISTRATION_SECONDS - 1
|
|
payload = dict(self.FORM)
|
|
payload.update(overrides)
|
|
return client.post('/auth/register', data=payload, follow_redirects=True)
|
|
|
|
def test_a_successful_sign_up_stores_both(self, app, client):
|
|
self._submit(client, app)
|
|
|
|
with app.app_context():
|
|
user = User.query.filter_by(username='newplayer').one()
|
|
tags = UserGamertag.query.filter_by(user_id=user.id).all()
|
|
assert [tag.gamertag for tag in tags] == ['newplayer#1234']
|
|
|
|
def test_a_failure_leaves_no_half_account(self, app, client, monkeypatch):
|
|
from app.routes import auth as auth_module
|
|
|
|
real_add = auth_module.db.session.add
|
|
|
|
def _explode_on_gamertag(instance, *args, **kwargs):
|
|
if isinstance(instance, UserGamertag):
|
|
raise RuntimeError('storage unavailable')
|
|
return real_add(instance, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(auth_module.db.session, 'add', _explode_on_gamertag)
|
|
|
|
with pytest.raises(RuntimeError):
|
|
self._submit(client, app)
|
|
|
|
with app.app_context():
|
|
assert User.query.filter_by(username='newplayer').first() is None
|