v0.8.21-alpha — Security: CSRF bypass + chained SSO-only account takeover
Security patch release on top of v0.8.20-alpha. All users should upgrade promptly. Tracked as GitHub Security Advisory GHSA-x4q4-3ww4-h329.
Fixed
-
CSRF bypass via the unauthenticated API token parameter (CWE-287, CVSS 7.1). The
csrf_exempt_for_api_tokensbefore_request hook calledcsrf.exempt(view_func)from inside a request handler whenever the request carried a token-shaped value, without validating the token against the database. Two consequences:csrf.exempt()mutates Flask-WTF's process-global_exempt_viewsset permanently, so the exemption persisted for the lifetime of the worker — one bogus request was enough to disable CSRF on the targeted endpoint indefinitely.- The query-string token (
?token=...) can be set by a Simple Cross-Origin Request without triggering CORS preflight, so a victim's browser could be tricked into making the disabling request from an attacker-controlled page. - Chained with the next finding, this enabled remote modification of arbitrary user state from a cross-origin attacker page: changing summary prompts, transcription settings, even toggling admin privileges.
The hook is gone. CSRF skipping is now a per-request decision made by a new
csrf_token_aware_checkbefore_request handler that calls a newload_user_from_token_headers_only()helper. That helper hashes the token, looks it up in the database, and checksis_valid(), and only accepts the token from the three request headers (Authorization, X-API-Token, API-Token) — never from the query string. Flask-WTF's automatic CSRF check is disabled (WTF_CSRF_CHECK_DEFAULT = False) so only our wrapper runs. -
Chained SSO-only account takeover in
/change_password. Whencurrent_user.passwordwas None (every SSO-only account), the current-password check was skipped and the route silently set a new password. Combined with the CSRF bypass above, this allowed an attacker to set a password on a victim's SSO-only account from a cross-origin page and then log in directly with the new credentials, bypassing SSO entirely. The route now refuses to act on accounts with no existing local password and directs users to the password-reset flow instead. To preserve the legitimate "add a password so I can later unlink SSO" workflow,/forgot-passwordnow also issues reset emails for SSO-only users; the user's email account is the proof-of-ownership gate (the same trust boundary every password-reset flow uses), so the takeover is closed while the flow keeps working.
Tests
tests/test_csrf_token_bypass_fix.py — 12 regression tests covering both attacks from the advisory and the backwards-compat surfaces: query-string and header fake-token rejection, the valid-header-token per-request bypass, the valid-query-string-token-on-non-v1 rejection, the SSO-only change_password refusal, the end-to-end SSO-only-add-password flow via forgot_password, the SMTP-not-configured safety net, all four documented API token methods on /api/v1/*, the ?token= POST on /api/v1/* (preserved by blueprint-level exemption), the browser session POST with a valid CSRF token, the change_password happy path for users with an existing password, and the Authorization: Bearer POST on /api/v1/*. The exempt-set immutability is asserted before and after each attempted bypass.
Credits
Reported privately under GitHub's Private Vulnerability Reporting by @Irench1k. Thank you for the detailed report, the working proof-of-concept, and the patience while the disclosure timeline aligned.
Migration
No configuration changes are required. Existing API-token automation continues to work via the v1 endpoints (still blueprint-level CSRF-exempt) and via header authentication on any other endpoint. Automation that relied on the ?token=... query parameter to bypass CSRF on state-changing requests will need to switch to header authentication; this was always the documented method.
Upgrade is the usual docker compose pull && docker compose up -d.