From c99f4d970edeb83b93bc8fe71886487dab2a4966 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 24 Jun 2026 05:26:07 +0200 Subject: [PATCH] fix(csrf): centraliser l'injection des tokens CSRF via un dcc.Store et un callback pattern-matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace les 7 callbacks CSRF individuels (un par formulaire, avec IDs string page-spécifiques) par un seul dcc.Store(id="csrf-token") dans le layout principal et un callback pattern-matching Output({"type": "csrf-input", "index": ALL}). Évite les erreurs Dash "id non trouvé dans le layout" sans recourir à suppress_callback_exceptions=True. Co-Authored-By: Claude Sonnet 4.6 --- src/app.py | 21 ++++++++--- src/pages/compte.py | 25 ++++++------- src/pages/connexion.py | 14 ++++---- src/pages/inscription.py | 14 ++++---- src/pages/mot_de_passe_oublie.py | 14 ++++---- src/pages/reinitialiser_mot_de_passe.py | 14 ++++---- tests/test_csrf_architecture.py | 48 +++++++++++++++++++++++++ 7 files changed, 100 insertions(+), 50 deletions(-) create mode 100644 tests/test_csrf_architecture.py diff --git a/src/app.py b/src/app.py index e2851be..dc223d5 100644 --- a/src/app.py +++ b/src/app.py @@ -5,11 +5,13 @@ import dash_bootstrap_components as dbc import pandas # noqa: F401 # eager import: avoid plotly's lazy-import race across Dash callback threads import tomllib from dash import ( + ALL, Dash, Input, Output, State, callback, + ctx, dcc, html, page_container, @@ -228,6 +230,7 @@ navbar = dbc.Navbar( app.layout = html.Div( [ + dcc.Store(id="csrf-token"), navbar, dbc.Container( page_container, @@ -272,7 +275,7 @@ def _auth_nav(_): children=[ dcc.Input( type="hidden", - id="csrf-navbar-logout", + id={"type": "csrf-input", "index": "navbar-logout"}, name="csrf_token", ), html.Button( @@ -293,10 +296,20 @@ def _auth_nav(_): @callback( - Output("csrf-navbar-logout", "value"), - Input("csrf-navbar-logout", "id"), + Output("csrf-token", "data"), + Input("_pages_location", "pathname"), + Input("auth-nav-slot", "children"), ) -def _csrf_navbar_logout(_): +def _generate_csrf_token(*_): from flask_wtf.csrf import generate_csrf return generate_csrf() + + +@callback( + Output({"type": "csrf-input", "index": ALL}, "value"), + Input("csrf-token", "data"), + prevent_initial_call=True, +) +def _fill_csrf_inputs(token): + return [token] * len(ctx.outputs_list) diff --git a/src/pages/compte.py b/src/pages/compte.py index f676196..63c8293 100644 --- a/src/pages/compte.py +++ b/src/pages/compte.py @@ -1,7 +1,6 @@ import dash_bootstrap_components as dbc -from dash import Input, Output, callback, dcc, html, register_page +from dash import dcc, html, register_page from flask_login import current_user -from flask_wtf.csrf import generate_csrf NAME = "Mon compte" @@ -42,7 +41,11 @@ def layout(error: str | None = None, password_changed: str | None = None, **_): method="POST", action="/auth/change-password", children=[ - dcc.Input(type="hidden", id="csrf-change", name="csrf_token"), + dcc.Input( + type="hidden", + id={"type": "csrf-input", "index": "change"}, + name="csrf_token", + ), dbc.Label("Mot de passe actuel"), dbc.Input( type="password", @@ -74,19 +77,13 @@ def layout(error: str | None = None, password_changed: str | None = None, **_): method="POST", action="/auth/logout", children=[ - dcc.Input(type="hidden", id="csrf-logout", name="csrf_token"), + dcc.Input( + type="hidden", + id={"type": "csrf-input", "index": "logout"}, + name="csrf_token", + ), dbc.Button("Déconnexion", type="submit", color="secondary"), ], ), ], ) - - -@callback(Output("csrf-change", "value"), Input("csrf-change", "id")) -def _fill_csrf_change(_): - return generate_csrf() - - -@callback(Output("csrf-logout", "value"), Input("csrf-logout", "id")) -def _fill_csrf_logout(_): - return generate_csrf() diff --git a/src/pages/connexion.py b/src/pages/connexion.py index 00b38ea..fe00058 100644 --- a/src/pages/connexion.py +++ b/src/pages/connexion.py @@ -1,6 +1,5 @@ import dash_bootstrap_components as dbc -from dash import Input, Output, callback, dcc, html, register_page -from flask_wtf.csrf import generate_csrf +from dash import dcc, html, register_page NAME = "Connexion" @@ -47,7 +46,11 @@ def layout(error: str | None = None, email: str | None = None, **kwargs): method="POST", action="/auth/login", children=[ - dcc.Input(type="hidden", id="csrf-login", name="csrf_token"), + dcc.Input( + type="hidden", + id={"type": "csrf-input", "index": "login"}, + name="csrf_token", + ), dcc.Input(type="hidden", name="next", value=next_url), dbc.Label("Adresse email"), dbc.Input( @@ -77,8 +80,3 @@ def layout(error: str | None = None, email: str | None = None, **kwargs): ), ], ) - - -@callback(Output("csrf-login", "value"), Input("csrf-login", "id")) -def _fill_csrf(_): - return generate_csrf() diff --git a/src/pages/inscription.py b/src/pages/inscription.py index e03c790..c6a3c90 100644 --- a/src/pages/inscription.py +++ b/src/pages/inscription.py @@ -1,6 +1,5 @@ import dash_bootstrap_components as dbc -from dash import Input, Output, callback, dcc, html, register_page -from flask_wtf.csrf import generate_csrf +from dash import dcc, html, register_page NAME = "Inscription" @@ -36,7 +35,11 @@ def layout(error: str | None = None, email: str | None = None, **_): method="POST", action="/auth/signup", children=[ - dcc.Input(type="hidden", id="csrf-signup", name="csrf_token"), + dcc.Input( + type="hidden", + id={"type": "csrf-input", "index": "signup"}, + name="csrf_token", + ), dbc.Label("Adresse email"), dbc.Input( type="email", @@ -68,8 +71,3 @@ def layout(error: str | None = None, email: str | None = None, **_): dcc.Link("Déjà un compte ? Se connecter", href="/connexion"), ], ) - - -@callback(Output("csrf-signup", "value"), Input("csrf-signup", "id")) -def _fill_csrf(_): - return generate_csrf() diff --git a/src/pages/mot_de_passe_oublie.py b/src/pages/mot_de_passe_oublie.py index c139820..56809c3 100644 --- a/src/pages/mot_de_passe_oublie.py +++ b/src/pages/mot_de_passe_oublie.py @@ -1,6 +1,5 @@ import dash_bootstrap_components as dbc -from dash import Input, Output, callback, dcc, html, register_page -from flask_wtf.csrf import generate_csrf +from dash import dcc, html, register_page NAME = "Mot de passe oublié" @@ -46,7 +45,11 @@ def layout( method="POST", action="/auth/request-password-reset", children=[ - dcc.Input(type="hidden", id="csrf-forgot", name="csrf_token"), + dcc.Input( + type="hidden", + id={"type": "csrf-input", "index": "forgot"}, + name="csrf_token", + ), dbc.Label("Adresse email"), dbc.Input( type="email", @@ -62,8 +65,3 @@ def layout( dcc.Link("Retour à la connexion", href="/connexion"), ], ) - - -@callback(Output("csrf-forgot", "value"), Input("csrf-forgot", "id")) -def _fill_csrf(_): - return generate_csrf() diff --git a/src/pages/reinitialiser_mot_de_passe.py b/src/pages/reinitialiser_mot_de_passe.py index d85b026..6e41ca8 100644 --- a/src/pages/reinitialiser_mot_de_passe.py +++ b/src/pages/reinitialiser_mot_de_passe.py @@ -1,6 +1,5 @@ import dash_bootstrap_components as dbc -from dash import Input, Output, callback, dcc, html, register_page -from flask_wtf.csrf import generate_csrf +from dash import dcc, html, register_page from src.auth.tokens import validate_password_reset_token @@ -47,7 +46,11 @@ def layout(token: str | None = None, error: str | None = None, **_): method="POST", action="/auth/reset-password", children=[ - dcc.Input(type="hidden", id="csrf-reset", name="csrf_token"), + dcc.Input( + type="hidden", + id={"type": "csrf-input", "index": "reset"}, + name="csrf_token", + ), dcc.Input(type="hidden", name="token", value=token), dbc.Label("Nouveau mot de passe (8 caractères minimum)"), dbc.Input( @@ -70,8 +73,3 @@ def layout(token: str | None = None, error: str | None = None, **_): ), ], ) - - -@callback(Output("csrf-reset", "value"), Input("csrf-reset", "id")) -def _fill_csrf(_): - return generate_csrf() diff --git a/tests/test_csrf_architecture.py b/tests/test_csrf_architecture.py new file mode 100644 index 0000000..6c7f20f --- /dev/null +++ b/tests/test_csrf_architecture.py @@ -0,0 +1,48 @@ +""" +Vérifie que les callbacks CSRF n'utilisent pas d'IDs string page-spécifiques +comme outputs, ce qui provoquerait des erreurs Dash "id non trouvé dans le layout". +""" + +import dash + + +def _find_component_id(component, target_id): + """Parcourt récursivement le layout Dash à la recherche d'un composant par id.""" + if hasattr(component, "id") and component.id == target_id: + return True + children = getattr(component, "children", None) + if isinstance(children, list): + return any(_find_component_id(c, target_id) for c in children) + if children is not None: + return _find_component_id(children, target_id) + return False + + +def test_csrf_token_store_in_main_layout(): + """dcc.Store(id='csrf-token') doit être dans le layout principal (toujours présent).""" + from src.app import app + + assert _find_component_id(app.layout, "csrf-token"), ( + "dcc.Store(id='csrf-token') manquant dans le layout principal. " + "Sans lui, les callbacks CSRF référencent des composants absents du layout initial." + ) + + +def test_no_page_specific_csrf_callback_outputs(): + """Aucun callback CSRF ne doit cibler un ID string page-spécifique en Output.""" + old_ids = { + "csrf-login.value", + "csrf-signup.value", + "csrf-forgot.value", + "csrf-change.value", + "csrf-logout.value", + "csrf-reset.value", + "csrf-navbar-logout.value", + } + + found = [k for k in dash._callback.GLOBAL_CALLBACK_MAP if k in old_ids] + + assert not found, ( + "Callbacks CSRF avec IDs string trouvés — provoquent des erreurs Dash au démarrage.\n" + + "\n".join(found) + )