SEC-AUTH-004. L'URL d'autorisation ne portait que client_id, redirect_uri,
response_type et scope. Sans state, le callback acceptait n'importe quel
code d'autorisation qu'on lui presentait.
Scenario ferme : un attaquant obtient un code pour SON compte Discord, puis
fait charger l'URL de callback par le navigateur de la victime. Le
formulaire d'inscription de la victime se retrouve pre-rempli avec
l'identite Discord de l'attaquant. C'est le login CSRF decrit par la
RFC 6749 §10.12.
Mise en oeuvre
secrets.token_urlsafe(32) genere le jeton, stocke en session avant la
redirection. Le callback le compare en temps constant avec
secrets.compare_digest, et le consomme systematiquement -- valide ou non --
pour qu'il ne puisse pas etre rejoue. Le controle intervient avant
l'echange du code : un callback rejete ne declenche aucun appel reseau.
Deux corrections accessoires sur le meme chemin
- DISCORD_REDIRECT_URI est desormais verifie au meme titre que
DISCORD_CLIENT_ID. Non defini, il faisait lever requests.utils.quote(None)
au lieu de signaler un probleme de configuration.
- la construction de la chaine de requete passe a urlencode() plutot qu'a
une concatenation manuelle.
9 tests : presence du state, stockage en session, unicite entre deux
demandes, rejet sans state, avec un state forge, sans demande prealable,
et non-rejouabilite.
Reste ouvert : l'identite Discord obtenue reste ensuite reinjectee par un
champ cache du formulaire (SEC-AUTH-005). Le state protege la liaison, pas
encore la valeur elle-meme.
Co-Authored-By: Claude Opus 5 <[email protected]>
124 lines
4.9 KiB
Python
124 lines
4.9 KiB
Python
"""Discord OAuth2 flow.
|
|
|
|
SEC-AUTH-004. The authorization URL carried client_id, redirect_uri,
|
|
response_type and scope — and nothing else. With no `state`, the callback
|
|
accepted any authorization code presented to it, so an attacker could have
|
|
a victim's browser consume a code issued for the attacker's own Discord
|
|
account (RFC 6749 §10.12, "login CSRF").
|
|
"""
|
|
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import pytest
|
|
|
|
from app.routes import auth as auth_module
|
|
|
|
|
|
@pytest.fixture
|
|
def discord_configured(monkeypatch):
|
|
"""Pretend the Discord application credentials are present.
|
|
|
|
They are read into module-level constants at import time, so they are
|
|
patched on the module rather than in the environment.
|
|
"""
|
|
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', '123456789012345678')
|
|
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_SECRET', 'not-a-real-secret')
|
|
monkeypatch.setattr(
|
|
auth_module, 'DISCORD_REDIRECT_URI',
|
|
'https://example.test/auth/discord/callback',
|
|
)
|
|
|
|
|
|
def _authorize_params(response):
|
|
return parse_qs(urlparse(response.headers['Location']).query)
|
|
|
|
|
|
class TestAuthorizationRequest:
|
|
def test_the_request_carries_a_state(self, client, discord_configured):
|
|
response = client.get('/auth/discord/login', follow_redirects=False)
|
|
|
|
assert response.status_code in (301, 302)
|
|
params = _authorize_params(response)
|
|
assert 'state' in params
|
|
assert len(params['state'][0]) >= 32
|
|
|
|
def test_the_state_is_stored_in_the_session(self, client, discord_configured):
|
|
response = client.get('/auth/discord/login', follow_redirects=False)
|
|
sent = _authorize_params(response)['state'][0]
|
|
|
|
with client.session_transaction() as sess:
|
|
assert sess[auth_module.DISCORD_STATE_KEY] == sent
|
|
|
|
def test_two_requests_get_different_states(self, client, discord_configured):
|
|
first = _authorize_params(
|
|
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
|
|
second = _authorize_params(
|
|
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
|
|
|
|
assert first != second
|
|
|
|
def test_scopes_and_redirect_are_preserved(self, client, discord_configured):
|
|
params = _authorize_params(
|
|
client.get('/auth/discord/login', follow_redirects=False))
|
|
|
|
assert params['scope'][0] == 'identify connections'
|
|
assert params['response_type'][0] == 'code'
|
|
assert params['redirect_uri'][0] == 'https://example.test/auth/discord/callback'
|
|
|
|
def test_a_missing_redirect_uri_is_reported_not_raised(self, client, monkeypatch):
|
|
"""Quoting an unset redirect_uri used to raise inside the query
|
|
builder instead of reporting a configuration problem."""
|
|
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', '123456789012345678')
|
|
monkeypatch.setattr(auth_module, 'DISCORD_REDIRECT_URI', None)
|
|
|
|
response = client.get('/auth/discord/login', follow_redirects=False)
|
|
|
|
assert response.status_code in (301, 302)
|
|
assert '/auth/register' in response.headers['Location']
|
|
|
|
|
|
class TestCallbackStateValidation:
|
|
"""The callback must reject anything it did not itself initiate.
|
|
|
|
None of these reach Discord: the state check happens before the token
|
|
exchange, so a rejected callback performs no network call.
|
|
"""
|
|
|
|
def test_a_callback_without_state_is_rejected(self, client, discord_configured):
|
|
client.get('/auth/discord/login', follow_redirects=False)
|
|
|
|
response = client.get(
|
|
'/auth/discord/callback?code=attacker-code', follow_redirects=False)
|
|
|
|
assert '/auth/register' in response.headers['Location']
|
|
|
|
def test_a_callback_with_a_wrong_state_is_rejected(self, client, discord_configured):
|
|
client.get('/auth/discord/login', follow_redirects=False)
|
|
|
|
response = client.get(
|
|
'/auth/discord/callback?code=attacker-code&state=forged',
|
|
follow_redirects=False)
|
|
|
|
assert '/auth/register' in response.headers['Location']
|
|
with client.session_transaction() as sess:
|
|
assert 'discord_oauth' not in sess
|
|
|
|
def test_a_callback_without_a_prior_request_is_rejected(self, client, discord_configured):
|
|
"""No /discord/login beforehand: nothing to match against."""
|
|
response = client.get(
|
|
'/auth/discord/callback?code=x&state=anything', follow_redirects=False)
|
|
|
|
assert '/auth/register' in response.headers['Location']
|
|
|
|
def test_the_state_is_single_use(self, client, discord_configured):
|
|
"""Consumed on the first callback, valid or not, so it cannot be
|
|
replayed."""
|
|
state = _authorize_params(
|
|
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
|
|
|
|
client.get(f'/auth/discord/callback?code=x&state={state}',
|
|
follow_redirects=False)
|
|
|
|
with client.session_transaction() as sess:
|
|
assert auth_module.DISCORD_STATE_KEY not in sess
|