GitHub Advisoryadvisory · PyPI
GHSA-f66q-9rf6-8795 Flask-Security-Too: WebAuthn reauthentication freshness bypass via cross-user assertion
MEDIUM
Official page ↗
- Ecosystem
- PyPI
- Packages
- Flask-Security-Too
- Severity
- Moderate
- CVSS vector
- CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N
- Weaknesses
- CWE-287, CWE-863
- Reviewed
- 2026-07-07
Affected packages
• PyPI Flask-Security-Too: >= 5.8.0, <= 5.8.1 (no fix yet)
Description
Summary
Flask-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, ResponseValue:
form = t.cast(
WebAuthnSigninResponseForm, build_form_from_request("wan_signin_response_form")
)
expired, invalid, state = check_and_get_token_status(
token, "wan", get_within_delta("WAN_SIGNIN_WITHIN")
)
...
form.challenge = state["challenge"]
form.user_verification = state["user_verification"]
form.is_secondary = False
form.is_verify = True
if form.validate_on_submit():
# update last use and sign count
after_this_request(view_commit)
assert form.cred
assert form.user
form.cred.lastuse_datetime = _security.datetime_factory()
form.cred.sign_count = form.authentication_verification.new_sign_count
_datastore.put(form.cred)
# verified - so set freshness time.
session["fs_paa"] = time.time()
...
```
`flask_security/webauthn.py:276-308` (the form's `validate()`):
```python
def validate(self, kwargs: t.Any) -> bool:
if not super().validate(kwargs):
return False # pragma: no cover
...
try:
auth_cred = parse_authentication_credential_json(self.credential.data)
except (...):
...
return False
# Look up credential Id (raw_id) and user. 7.2.6/7
self.cred = _datastore.find_webauthn(credential_id=auth_cred.raw_id)
...
# This shouldn't be able to happen if datastore properly cascades delete
self.user = _datastore.find_user_from_webauthn(self.cred)
```
`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`) carries only `{challenge, user_verification}`, so state tokens are not bound to any user and replay portably across sessions:
```python
def _signin_common(user: UserMixin | None, usage: list[str]) -> tuple[t.Any, str]:
...
state = {
"challenge": challenge,
"user_verification": uv,
}
...
state_token = t.cast(str, _security.wan_serializer.dumps(state))
return o_json, state_token
```
Contrast with the patch in `oauth_glue.py:211` that `GHSA-97r5-pg8x-p63p` shipped:
```python
next_loc = session.pop("fs_oauth_next", None)
if user and user.email == current_user.email:
# verified - so set freshness time.
session["fs_paa"] = time.time()
```
That `user.email == current_user.email` clamp is the missing check on the WebAuthn side.
How input reaches the sink
1. Attacker 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).
2. 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=...)`).
3. The victim session calls `POST /wan-verify` and receives a `wan_state`
token. The state token has no user binding.
4. Attacker submits an assertion that proves possession of `cred_attacker`,
inside the victim session, to `POST /wan-verify/`.
5. `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.
6. `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.
7. Any subsequent `@auth_required(within=...)` endpoint now succeeds inside
the victim session.
End-to-end reproduction
Reproduction 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:
```bash
python3.12 -m venv venv
source venv/bin/activate
pip install --quiet 'Flask-Security-Too==5.8.0' Flask-SQLAlchemy webauthn email-validator argon2_cffi
python poc.py
```
Captured run-time output (5.8.0 path):
```
=== Submit BOB's WebAuthn assertion to Alice's /wan-verify-response ===
cross-user assertion status: 200
alice fs_uniquifier in session AFTER: '408245d132bc4213a55606c46f40e038' # still Alice
fs_paa BEFORE: 1779582282.550872
fs_paa AFTER : 1779585882.615287 # advanced
=== Demonstrate impact: /sensitive (freshness-protected) accepted ===
/sensitive after cross-user verify status: 200
```
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 concept
Mocked WebAuthn fixtures (`REG_DATA_UV`, `SIGNIN_DATA_UV`, `REG_DATA1`, `SIGNIN_DATA1`) and `HackW…