[GHSA-fg6f-75jq-6523] Authlib has 1-click Account Takeover vulnerability#7138
[GHSA-fg6f-75jq-6523] Authlib has 1-click Account Takeover vulnerability#7138galgolamiel wants to merge 1 commit intogalgolamiel/advisory-improvement-7138from
Conversation
|
Hi there @lepture! A community member has suggested an improvement to your security advisory. If approved, this change will affect the global advisory listed at github.com/advisories. It will not affect the version listed in your project repository. This change will be reviewed by our Security Curation Team. If you have thoughts or feedback, please share them in a comment here! If this PR has already been closed, you can start a new community contribution for this advisory |
There was a problem hiding this comment.
Pull request overview
This PR aims to update the affected version range for the Authlib 1-click Account Takeover vulnerability advisory (GHSA-fg6f-75jq-6523 / CVE-2025-68158). The rationale is that versions prior to 0.15.6 are not affected because they use session-based state management rather than the vulnerable cache-backed storage pattern.
Changes:
- Overwrites the
summaryanddetailsfields with the informal issue request text instead of the original advisory content - Adds a new affected version range entry (
0.15.6to1.6.6) without removing/updating the existing range (0to1.6.6), creating an overlap - Removes a commit reference (
7974f45e...) from the references list
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ], | ||
| "summary": "Authlib has 1-click Account Takeover vulnerability", | ||
| "details": "I am writing to you from the Security Labs team at Snyk to report a security issue affecting Authlib, which we identified during a recent research project.\n\nWe have identified a vulnerability that can result in a 1-click Account Takeover in applications that use the Authlib library. (5.7 CVSS v3: AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:N/A:N)\n\n**Description**\n\nCache-backed state/request-token storage is not tied to the initiating user session, so CSRF is possible for any attacker that has a valid state (easily obtainable via an attacker-initiated authentication flow). When a cache is supplied to the OAuth client registry, `FrameworkIntegration.set_state_data` writes the entire state blob under `_state_{app}_{state},` and `get_state_data` ignores the caller’s session altogether. \\[1\\]\\[2\\]\n\n```py\n def _get_cache_data(self, key):\n value = self.cache.get(key)\n if not value:\n return None\n try:\n return json.loads(value)\n except (TypeError, ValueError):\n return None\n[snip]\n def get_state_data(self, session, state):\n key = f\"_state_{self.name}_{state}\"\n if self.cache:\n value = self._get_cache_data(key)\n else:\n value = session.get(key)\n if value:\n return value.get(\"data\")\n return None\n```\n\n*authlib/integrations/base\\_client/framework\\_integration.py:12-41*\n\nRetrieval in authorize\\_access\\_token therefore succeeds for whichever browser presents that opaque value, and the token exchange proceeds with the attacker’s authorization code. \\[3\\]\n\n```py\n def authorize_access_token(self, **kwargs):\n \"\"\"Fetch access token in one step.\n\n :return: A token dict.\n \"\"\"\n params = request.args.to_dict(flat=True)\n state = params.get(\"oauth_token\")\n if not state:\n raise OAuthError(description='Missing \"oauth_token\" parameter')\n\n data = self.framework.get_state_data(session, state)\n if not data:\n raise OAuthError(description='Missing \"request_token\" in temporary data')\n\n params[\"request_token\"] = data[\"request_token\"]\n params.update(kwargs)\n self.framework.clear_state_data(session, state)\n token = self.fetch_access_token(**params)\n self.token = token\n return token\n```\n\n*authlib/integrations/flask\\_client/apps.py:57-76*\n\nThis opens up the avenue for Login CSRF for apps that use the cache-backed storage. Depending on the dependent app’s implementation (whether it somehow links accounts in the case of a login CSRF), this could lead to account takeover.\n\n\\[1\\] [https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/flask\\_client/apps.py\\#L35](https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/flask_client/apps.py#L35)\n\n\\[2\\] [https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/base\\_client/framework\\_integration.py\\#L33](https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/base_client/framework_integration.py#L33)\n\n\\[3\\] [https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/flask\\_client/apps.py\\#L57](https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/flask_client/apps.py#L57)\n\n**Proof of Concept**\n\nLet’s think of an app \\- AwesomeAuthlibApp. Let’s assume that the AwesomeAuthlibApp has internal logic that, when an already logged-in user performs a `callback` request, links the newly provided SSO identity to the already existing user that made the request.\n\nThen, an attacker can get account takeover inside the app by performing the following actions:\n\n1\\. They start an SSO OAuth flow, but stop it right before making the callback call to AwesomeAuthlibApp; \n2\\. The attacker tricks a logged-in user (via phishing, a drive-by attack, etc.) to perform a GET request with the attacker's state value and grant code to the AwesomeAuthlibApp callback. Because Authlib doesn’t check whether the state token is linked to the session performing the callback, the callback is processed, the grant code is sent to the provider, and the account linking takes place.\n\nAfter the GET request is performed, the attacker's SSO account is linked with the victim's AwesomeAuthlibApp account permanently.\n\n**Suggested Fix**\n\nPer the OAuth RFC \\[4\\], the state should be tied to the user’s session to stop exactly such scenarios. One straightforward method of mitigating this issue is to keep storing the state in the session even when caching.\n\nAnother method would be to hash the session ID (or another per-user secret from the session) into the cache key. This way, the state will be stored inside the cache, but it is still linked to the session of the user that initiated the OAuth flow.\n\n[4] https://www.rfc-editor.org/rfc/rfc6749#section-10.12", | ||
| "summary": "update version range on security advisory : https://github.com/advisories/GHSA-fg6f-75jq-6523", |
There was a problem hiding this comment.
The summary field has been replaced with an informal issue request message ("update version range on security advisory : ...") instead of a proper vulnerability description. The convention for advisory summary fields is a concise description of the vulnerability (e.g., "Authlib has 1-click Account Takeover vulnerability" as it was before). The original summary text should be preserved; only the version range in the affected section should be updated.
| "summary": "update version range on security advisory : https://github.com/advisories/GHSA-fg6f-75jq-6523", | |
| "summary": "Authlib has 1-click Account Takeover vulnerability", |
| "summary": "Authlib has 1-click Account Takeover vulnerability", | ||
| "details": "I am writing to you from the Security Labs team at Snyk to report a security issue affecting Authlib, which we identified during a recent research project.\n\nWe have identified a vulnerability that can result in a 1-click Account Takeover in applications that use the Authlib library. (5.7 CVSS v3: AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:N/A:N)\n\n**Description**\n\nCache-backed state/request-token storage is not tied to the initiating user session, so CSRF is possible for any attacker that has a valid state (easily obtainable via an attacker-initiated authentication flow). When a cache is supplied to the OAuth client registry, `FrameworkIntegration.set_state_data` writes the entire state blob under `_state_{app}_{state},` and `get_state_data` ignores the caller’s session altogether. \\[1\\]\\[2\\]\n\n```py\n def _get_cache_data(self, key):\n value = self.cache.get(key)\n if not value:\n return None\n try:\n return json.loads(value)\n except (TypeError, ValueError):\n return None\n[snip]\n def get_state_data(self, session, state):\n key = f\"_state_{self.name}_{state}\"\n if self.cache:\n value = self._get_cache_data(key)\n else:\n value = session.get(key)\n if value:\n return value.get(\"data\")\n return None\n```\n\n*authlib/integrations/base\\_client/framework\\_integration.py:12-41*\n\nRetrieval in authorize\\_access\\_token therefore succeeds for whichever browser presents that opaque value, and the token exchange proceeds with the attacker’s authorization code. \\[3\\]\n\n```py\n def authorize_access_token(self, **kwargs):\n \"\"\"Fetch access token in one step.\n\n :return: A token dict.\n \"\"\"\n params = request.args.to_dict(flat=True)\n state = params.get(\"oauth_token\")\n if not state:\n raise OAuthError(description='Missing \"oauth_token\" parameter')\n\n data = self.framework.get_state_data(session, state)\n if not data:\n raise OAuthError(description='Missing \"request_token\" in temporary data')\n\n params[\"request_token\"] = data[\"request_token\"]\n params.update(kwargs)\n self.framework.clear_state_data(session, state)\n token = self.fetch_access_token(**params)\n self.token = token\n return token\n```\n\n*authlib/integrations/flask\\_client/apps.py:57-76*\n\nThis opens up the avenue for Login CSRF for apps that use the cache-backed storage. Depending on the dependent app’s implementation (whether it somehow links accounts in the case of a login CSRF), this could lead to account takeover.\n\n\\[1\\] [https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/flask\\_client/apps.py\\#L35](https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/flask_client/apps.py#L35)\n\n\\[2\\] [https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/base\\_client/framework\\_integration.py\\#L33](https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/base_client/framework_integration.py#L33)\n\n\\[3\\] [https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/flask\\_client/apps.py\\#L57](https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/flask_client/apps.py#L57)\n\n**Proof of Concept**\n\nLet’s think of an app \\- AwesomeAuthlibApp. Let’s assume that the AwesomeAuthlibApp has internal logic that, when an already logged-in user performs a `callback` request, links the newly provided SSO identity to the already existing user that made the request.\n\nThen, an attacker can get account takeover inside the app by performing the following actions:\n\n1\\. They start an SSO OAuth flow, but stop it right before making the callback call to AwesomeAuthlibApp; \n2\\. The attacker tricks a logged-in user (via phishing, a drive-by attack, etc.) to perform a GET request with the attacker's state value and grant code to the AwesomeAuthlibApp callback. Because Authlib doesn’t check whether the state token is linked to the session performing the callback, the callback is processed, the grant code is sent to the provider, and the account linking takes place.\n\nAfter the GET request is performed, the attacker's SSO account is linked with the victim's AwesomeAuthlibApp account permanently.\n\n**Suggested Fix**\n\nPer the OAuth RFC \\[4\\], the state should be tied to the user’s session to stop exactly such scenarios. One straightforward method of mitigating this issue is to keep storing the state in the session even when caching.\n\nAnother method would be to hash the session ID (or another per-user secret from the session) into the cache key. This way, the state will be stored inside the cache, but it is still linked to the session of the user that initiated the OAuth flow.\n\n[4] https://www.rfc-editor.org/rfc/rfc6749#section-10.12", | ||
| "summary": "update version range on security advisory : https://github.com/advisories/GHSA-fg6f-75jq-6523", | ||
| "details": "hey,\nplease update version range on security advisory : https://github.com/advisories/GHSA-fg6f-75jq-6523 .\ncurrently version range is <= 1.6.5 .\nThis is not affecting version 0.15.5 - due to the reasons below:\n\nin short - not relevant to this version:\n\nThe state is tied to the user's session\nAn attacker cannot supply a valid state from their own session to be used in the victim's session\nThe get_session_data method uses session.pop() which removes the state from the session, ensuring it's only used once\nmore detailed:\nState is stored in request.session, which is tied to the user's session cookie\nAn attacker cannot access the victim's session (assuming proper session security)\nIf an attacker tricks a victim into visiting the callback with the attacker's state, get_session_data will return None (the victim's session doesn't have that state), and validation fails\nThis is different from the cache-backed vulnerability where state was stored globally and not bound to a user session.\n\nThe architecture differs from the vulnerable pattern described in the CVE:\n\nVersion 0.15.5 uses a simpler state management system:\nset_session_data() and get_session_data() store data in the session directly using keys like _{name}_authlib_{key}_\nCache is only used for OAuth1 request tokens (not OAuth2 state), and even then it stores a session ID in the session and the actual token in cache (see authlib/integrations/flask_client/oauth_registry.py )\nState management:\n1.OAuth2 state is stored directly in session via set_session_data(request, 'state', state)\n2.Retrieved via get_session_data(request, 'state') which pops it from session enforces one-time use.\n3.The session key format includes the client name: _dev_authlib_state_\n4.There is NO cache-backed state storage pattern (Cache is only used for OAuth1 tokens with session-bound keys.)\n\nWhat category best describes your issue?\nFeature request", |
There was a problem hiding this comment.
The details field has been overwritten with the informal issue/feature request body (starting with "hey,\nplease update version range...") instead of retaining the original detailed technical security advisory description. This removes critical vulnerability documentation including the technical description, proof of concept, code references, and suggested fix. The original details content should be preserved — only the affected version ranges should be modified. See other advisories for the expected format (e.g., GHSA-232v-j27c-5pp6, GHSA-fh55-r93g-j68g).
| "details": "hey,\nplease update version range on security advisory : https://github.com/advisories/GHSA-fg6f-75jq-6523 .\ncurrently version range is <= 1.6.5 .\nThis is not affecting version 0.15.5 - due to the reasons below:\n\nin short - not relevant to this version:\n\nThe state is tied to the user's session\nAn attacker cannot supply a valid state from their own session to be used in the victim's session\nThe get_session_data method uses session.pop() which removes the state from the session, ensuring it's only used once\nmore detailed:\nState is stored in request.session, which is tied to the user's session cookie\nAn attacker cannot access the victim's session (assuming proper session security)\nIf an attacker tricks a victim into visiting the callback with the attacker's state, get_session_data will return None (the victim's session doesn't have that state), and validation fails\nThis is different from the cache-backed vulnerability where state was stored globally and not bound to a user session.\n\nThe architecture differs from the vulnerable pattern described in the CVE:\n\nVersion 0.15.5 uses a simpler state management system:\nset_session_data() and get_session_data() store data in the session directly using keys like _{name}_authlib_{key}_\nCache is only used for OAuth1 request tokens (not OAuth2 state), and even then it stores a session ID in the session and the actual token in cache (see authlib/integrations/flask_client/oauth_registry.py )\nState management:\n1.OAuth2 state is stored directly in session via set_session_data(request, 'state', state)\n2.Retrieved via get_session_data(request, 'state') which pops it from session enforces one-time use.\n3.The session key format includes the client name: _dev_authlib_state_\n4.There is NO cache-backed state storage pattern (Cache is only used for OAuth1 tokens with session-bound keys.)\n\nWhat category best describes your issue?\nFeature request", | |
| "details": "Authlib contains an implementation of OAuth 2.0 client integrations which, in certain configurations and versions, does not correctly bind the `state` parameter to an individual user's session. In affected versions, the OAuth2 state value used to protect the authorization response can be stored in a way that allows it to be shared or reused across different browser sessions instead of being strictly scoped to a single user.\n\nThis breaks the CSRF protection guarantees of the OAuth 2.0 state mechanism. An attacker who can initiate an OAuth flow in their own browser, capture the resulting `state` value, and then trick a victim into following a crafted authorization or callback URL can cause the victim's browser to complete the attacker's authorization flow. Depending on how the application uses the OAuth2 client, this can lead to account linking or account takeover scenarios, such as attaching the attacker's third-party account to the victim's local account or authorizing actions on behalf of the victim.\n\nThe vulnerability arises when the state value is stored in a globally shared cache or similar backend that is not bound to an individual session and is not enforced as single-use. Because the state is not tied to the victim's session, the application may accept a state value that was generated in the attacker's session. This differs from safe patterns where state is stored directly in the user's session and removed (popped) after a single use, which prevents reuse across users and requests.\n\nIn contrast, older versions such as 0.15.5 use a simpler session-bound state management approach in which `set_session_data()` and `get_session_data()` store OAuth2 state directly in the per-user session (e.g., keys like `_{name}_authlib_{key}_`), and the state is removed from the session on retrieval. Because the state is tied to the user's session cookie and consumed once, an attacker cannot reuse a state value from their own session in a victim's session, and those versions are not affected by this CVE.\n\nThis advisory covers the cache-backed and otherwise improperly scoped state handling introduced in later releases. The issue is remediated in version 1.6.6, which ensures that OAuth2 state is correctly bound to the user's session and used only once, restoring the intended CSRF protection.", |
| { | ||
| "package": { | ||
| "ecosystem": "PyPI", | ||
| "name": "authlib" | ||
| }, | ||
| "ranges": [ | ||
| { | ||
| "type": "ECOSYSTEM", | ||
| "events": [ | ||
| { | ||
| "introduced": "0.15.6" | ||
| }, | ||
| { | ||
| "fixed": "1.6.6" | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| }, |
There was a problem hiding this comment.
The new affected range (introduced: "0.15.6", fixed: "1.6.6") was added alongside the existing range (introduced: "0", fixed: "1.6.6"), but the old range was not removed or updated. This creates two overlapping entries for the same package — the new range is entirely contained within the old one. If the intent is to narrow the affected range to start at 0.15.6 (as described in the PR), the old entry (lines 37-58) with introduced: "0" should be updated to use introduced: "0.15.6" instead of adding a duplicate entry. Alternatively, if the old entry needs to remain for backward compatibility, it should at least have its introduced value updated to match.
Updates
Comments
as to the above , version range should be updated.