GHSA-f66q-9rf6-8795 Flask-Security-Too: WebAuthn reauthentication freshness bypass via cross-user assertion
SummaryFlask-Security-Too 5.8.0 and 5.8.1 mark a session as reauthentication-fresh after processing a WebAuthn assertion whose proven credential belongs to a different user than the currently authenticated session user. The check that `GHSA-97r5-pg8x-p63p` added on the OAuth reauthentication path (`user.email == current_user.email`) is missing on the WebAuthn reauthentication path. An attacker who owns any WebAuthn credential registered to any account on the deployment can satisfy a victim session's freshness gate by submitting their own WebAuthn proof into the victim session.Affected versions`Flask-Security-Too` `>= 5.8.0, <= 5.8.1` (current `main` commit `5c44c76e33a20b67d02115e26d2da4bab18c094e`).
`GHSA-97r5-pg8x-p63p` (published 2026-05-22) shipped its fix in 5.8.1 only on `oauth_glue.py`; `webauthn.py` was not touched and remains exploitable in 5.8.1.Privilege requiredAuthenticated attacker on the same Flask-Security deployment, owning at least one WebAuthn credential of any usage (`first` / `secondary` / verify) that is registered to their own account. The attacker also needs the ability to drive HTTP requests against the WebAuthn endpoints inside the victim session (e.g. a separate gadget such as CSRF + cookie-based auth, an XSS that doesn't reach the cookie itself but can move the session through endpoints, or an existing session-fixation gadget; or the rarer but easier case of an attacker who has direct access to the victim's not-yet-fresh session via a shared browser). The point of the freshness gate is to defend exactly that "I have the session but it isn't fresh enough to do sensitive things" position, so any context in which freshness would have protected the victim is also the context in which this bypass matters.Vulnerable code[`flask_security/webauthn.py:846-889`](https://github.com/pallets-eco/flask-security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/flask_security/webauthn.py#L846-L889) (commit
`5c44c76e33a20b67d02115e26d2da4bab18c094e`):[`flask_security/webauthn.py:276-308`](https://github.com/pallets-eco/flask-security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/flask_security/webauthn.py#L276-L308) (the form's `validate()`):`self.user` is resolved from the attacker-controlled `credential_id` and is never compared to `current_user`. The state token issued by `_signin_common` ([`webauthn.py:589-622`](https://github.com/pallets-eco/flask-security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/webauthn.py#L589-L622)) carries only `{challenge, user_verification}`, so state tokens are not bound to any user and replay portably across sessions:Contrast with the patch in [`oauth_glue.py:211`](https://github.com/pallets-eco/flask-security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/oauth_glue.py#L211) that `GHSA-97r5-pg8x-p63p` shipped:That `user.email == current_user.email` clamp is the missing check on the WebAuthn side.How input reaches the sinkAttacker logs in to their own account and registers their own WebAuthn
credential (call it `cred_attacker`). They retain a copy of any valid
`navigator.credentials.get()` assertion JSON produced by their authenticator
(one signature is enough; can also be produced fresh on demand per request).Attacker holds, or gets, a victim session in a state where `fs_paa` is past
`FRESHNESS`. The victim is authenticated as themselves; the gate stops them
from invoking freshness-protected business endpoints (`/change`,
`/change-username`, `/wf-add`, `/us-setup`, anything decorated with
`@auth_required(within=...)`).The victim session calls `POST /wan-verify` and receives a `wan_state`
token. The state token has no user binding.Attacker submits an assertion that proves possession of `cred_attacker`,
inside the victim session, to `POST /wan-verify/<wan_state>`.`WebAuthnSigninResponseForm.validate` resolves `form.user` to the attacker
account from `find_user_from_webauthn(self.cred)`, signs/verifies the
assertion against the (attacker-controlled) public key it stored at
registration time, and returns `True`. The user-handle check on
`auth_cred.response.user_handle` (if present) compares against
`self.user.fs_webauthn_user_handle`, i.e. it compares attacker user-handle
to attacker user, so it passes trivially.`webauthn_verify_response` then writes `session["fs_paa"] = time.time()`.
The session user is unchanged (still the victim) but the freshness clock
is reset by a cryptographic proof of the attacker's authenticator.Any subsequent `@auth_required(within=...)` endpoint now succeeds inside
the victim session.End-to-end reproductionReproduction is an in-process Flask test client driving the published wheel (`pip install Flask-Security-Too==5.8.0`, also re-run against 5.8.1 since `GHSA-97r5-pg8x-p63p`'s fix shipped with that release only touched `oauth_glue.py`). The full transcript is in the Proof of concept section below; here is the boot recipe:Captured run-time output (5.8.0 path):Re-run against 5.8.1 produces the same `200` on the cross-user assertion and the same `200` on the freshness-gated endpoint, confirming that the patch for `GHSA-97r5-pg8x-p63p` did not extend to the WebAuthn path.Proof of conceptMocked WebAuthn fixtures (`REG_DATA_UV`, `SIGNIN_DATA_UV`, `REG_DATA1`, `SIGNIN_DATA1`) and `HackWebauthnUtil` are lifted verbatim from the project's own test suite ([`tests/test_webauthn.py`](https://github.com/pallets-eco/flask security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/tests/test_webauthn.py)) which pins the challenge so a recorded assertion blob can be replayed; this does not bypass any cryptographic check inside `webauthn.verify_authentication_response`, it just substitutes the test-suite's own `WebauthnUtil` so the recorded blobs can be exercised against a running app instance. In a real-world deployment the attacker uses their own authenticator producing fresh assertions per request.`poc.py` (complete, runnable; the `REG_DATA*` / `SIGNIN_DATA*` fixtures are the project's own `tests/test_webauthn.py` blobs, reproduced in full):Verbatim run-time output against the published `Flask-Security-Too==5.8.0`
wheel (`$ python poc.py`):Re-run against the published `Flask-Security-Too==5.8.1` wheel (the release
that shipped the `GHSA-97r5-pg8x-p63p` OAuth fix) is identical — the
cross-user assertion is still accepted (`200`) and the freshness-gated
endpoint is still reachable (`200`), confirming the parent fix did not
extend to the WebAuthn path:The session user remains Alice (`fs_uniquifier` unchanged), but `fs_paa`
advances and the freshness-gated endpoint accepts the request, even though
the only cryptographic proof presented was Bob's WebAuthn signature.ImpactBypass of `@auth_required(within=...)` freshness gates on the WebAuthn
reauthentication path. Any sensitive operation that relies on freshness
(built-in: `/change` password change, `/change-username`, `/wf-add` to
register a new WebAuthn credential, `/us-setup` to (re)configure unified
signin, `/mf-recovery-codes`; app-defined: any business route the
application protected with `@auth_required(within=...)`) is reachable
from an attacker-held victim session.Promotes any session-handoff or session-holder gadget from "victim still
protected against sensitive ops" to "attacker reaches sensitive ops" using
the attacker's own authenticator.Same trust-contract violation that `GHSA-97r5-pg8x-p63p` (rated medium)
was published to close on the OAuth path. The WebAuthn variant is
reachable wherever the project's WebAuthn-verify is enabled.Suggested fixAdd the equivalent of the OAuth fix in
`flask_security/webauthn.py:webauthn_verify_response` so the cryptographically
verified user must equal the currently authenticated session user before
freshness is advanced:Equivalent pattern (and arguably tighter) is to add a bind into the state
token issued by `_signin_common` when called from `webauthn_verify` (the
caller already holds `form.user = current_user`):and check it in `WebAuthnSigninResponseForm.validate` when the form is being
used for verify (`self.is_verify`). Either fix shape closes the bug; the
`current_user`-bind shape mirrors [`oauth_glue.py:211`](https://github.com/pallets-eco/flask-security/blob/5c44c76e33a20b67d02115e26d2da4bab18c094e/oauth_glue.py#L211) more directly. The
`/wan-signin` flow (`is_verify == False`) does not need to change — it is the
primary-signin path where there is by design no `current_user` yet.Fix PRTo follow on the advisory's temp private fork once it is provisioned.CreditReported by tonghuaroot.
CVSS v4.0 score: 5.3