github murtaza-nasir/speakr v0.8.21-alpha
v0.8.21-alpha — Security: CSRF bypass + SSO account takeover

12 hours ago

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_tokens before_request hook called csrf.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_views set 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_check before_request handler that calls a new load_user_from_token_headers_only() helper. That helper hashes the token, looks it up in the database, and checks is_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. When current_user.password was 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-password now 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.

Don't miss a new speakr release

NewReleases is sending notifications on new releases.