mirror of
https://github.com/openembedded/meta-openembedded.git
synced 2026-11-13 03:37:15 +00:00
python3-pyjwt: fix CVE-2026-101917
This patch applies the upstream fix as referenced in [2], using the commit shown in [1]. [1] https://github.com/jpadilla/pyjwt/commit/ba4853a75fb9676362da17f67d0f64bd18afd4e1 [2] https://github.com/jpadilla/pyjwt/security/advisories/GHSA-2gx3-rcp4-g85q Signed-off-by: Hetvi Thakar <hthakar@cisco.com> Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
This commit is contained in:
1 parent
95a0df87a1
commit
395753da37
2 files changed
+312
No files matched your search
@@ -0,0 +1,311 @@
|
||||
From ba4853a75fb9676362da17f67d0f64bd18afd4e1 Mon Sep 17 00:00:00 2001
|
||||
From: =?UTF-8?q?Jos=C3=A9=20Padilla?= <jpadilla@users.noreply.github.com>
|
||||
Date: Thu, 10 Sep 2026 14:10:52 -0400
|
||||
Subject: [PATCH] Throttle repeated PyJWKClient refreshes
|
||||
|
||||
CVE: CVE-2026-101917
|
||||
Upstream-Status: Backport [https://github.com/jpadilla/pyjwt/commit/ba4853a75fb9676362da17f67d0f64bd18afd4e1]
|
||||
|
||||
(cherry picked from commit ba4853a75fb9676362da17f67d0f64bd18afd4e1)
|
||||
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
|
||||
---
|
||||
jwt/jwks_client.py | 63 ++++++++++++++---
|
||||
tests/test_jwks_client.py | 141 +++++++++++++++++++++++++++++++++++++-
|
||||
2 files changed, 192 insertions(+), 12 deletions(-)
|
||||
|
||||
diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py
|
||||
index cfc9003..dcf1edd 100644
|
||||
--- a/jwt/jwks_client.py
|
||||
+++ b/jwt/jwks_client.py
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
+import math
|
||||
+import threading
|
||||
+import time
|
||||
import urllib.request
|
||||
from functools import lru_cache
|
||||
from ssl import SSLContext
|
||||
@@ -38,6 +41,7 @@ class PyJWKClient:
|
||||
headers: dict[str, Any] | None = None,
|
||||
timeout: float = 30,
|
||||
ssl_context: SSLContext | None = None,
|
||||
+ cooldown_duration: float = 30,
|
||||
):
|
||||
"""A client for retrieving signing keys from a JWKS endpoint.
|
||||
|
||||
@@ -54,6 +58,14 @@ class PyJWKClient:
|
||||
- ``lifespan``: Time in seconds before the cached JWK Set expires.
|
||||
Defaults to ``300`` (5 minutes). Must be greater than 0.
|
||||
|
||||
+ Unknown key IDs can trigger one forced refresh after the cooldown
|
||||
+ period configured by ``cooldown_duration``. Every successful fetch
|
||||
+ starts this cooldown, including the initial fetch and cache-expiry
|
||||
+ fetches. A newly rotated key may therefore wait for the cooldown
|
||||
+ period before it is fetched; set ``cooldown_duration`` to ``0`` to
|
||||
+ disable this behavior. The cooldown is bypassed when
|
||||
+ ``cache_jwk_set`` is ``False``.
|
||||
+
|
||||
**Tier 2 — Signing key cache** (disabled by default):
|
||||
Caches individual signing keys (looked up by ``kid``) using an LRU
|
||||
cache with **no time-based expiration**. Keys are evicted only when
|
||||
@@ -80,6 +92,9 @@ class PyJWKClient:
|
||||
:type timeout: float
|
||||
:param ssl_context: Optional SSL context for the request.
|
||||
:type ssl_context: ssl.SSLContext or None
|
||||
+ :param cooldown_duration: Minimum time in seconds between forced
|
||||
+ refreshes after an unknown key ID. Defaults to ``30``.
|
||||
+ :type cooldown_duration: float
|
||||
"""
|
||||
if headers is None:
|
||||
headers = {}
|
||||
@@ -98,6 +113,18 @@ class PyJWKClient:
|
||||
self.headers = headers
|
||||
self.timeout = timeout
|
||||
self.ssl_context = ssl_context
|
||||
+ if cooldown_duration < 0:
|
||||
+ raise PyJWKClientError(
|
||||
+ "Cooldown duration must be greater than or equal to 0, "
|
||||
+ f'the input is "{cooldown_duration}"'
|
||||
+ )
|
||||
+ if not math.isfinite(cooldown_duration):
|
||||
+ raise PyJWKClientError(
|
||||
+ f'Cooldown duration must be finite, the input is "{cooldown_duration}"'
|
||||
+ )
|
||||
+ self.cooldown_duration = cooldown_duration
|
||||
+ self._last_successful_fetch: float | None = None
|
||||
+ self._client_lock = threading.RLock()
|
||||
|
||||
if cache_jwk_set:
|
||||
# Init jwt set cache with default or given lifespan.
|
||||
@@ -147,6 +174,7 @@ class PyJWKClient:
|
||||
# wipe that breaks legitimate auth.
|
||||
if self.jwk_set_cache is not None:
|
||||
self.jwk_set_cache.put(jwk_set)
|
||||
+ self._last_successful_fetch = time.monotonic()
|
||||
return jwk_set
|
||||
|
||||
def get_jwk_set(self, refresh: bool = False) -> PyJWKSet:
|
||||
@@ -186,6 +214,10 @@ class PyJWKClient:
|
||||
:raises PyJWKClientError: If no signing keys are found.
|
||||
"""
|
||||
jwk_set = self.get_jwk_set(refresh)
|
||||
+ return self._get_signing_keys_from_jwk_set(jwk_set)
|
||||
+
|
||||
+ @staticmethod
|
||||
+ def _get_signing_keys_from_jwk_set(jwk_set: PyJWKSet) -> list[PyJWK]:
|
||||
signing_keys = [
|
||||
jwk_set_key
|
||||
for jwk_set_key in jwk_set.keys
|
||||
@@ -200,8 +232,9 @@ class PyJWKClient:
|
||||
def get_signing_key(self, kid: str) -> PyJWK:
|
||||
"""Return the signing key matching the given ``kid``.
|
||||
|
||||
- If no match is found in the current JWK Set, the set is
|
||||
- refreshed from the endpoint and the lookup is retried once.
|
||||
+ If no match is found in the current JWK Set, the set is refreshed
|
||||
+ from the endpoint and the lookup is retried once when the refresh
|
||||
+ cooldown permits it.
|
||||
|
||||
:param kid: The key ID to look up.
|
||||
:type kid: str
|
||||
@@ -210,20 +243,28 @@ class PyJWKClient:
|
||||
:raises PyJWKClientError: If no matching key is found after
|
||||
refreshing.
|
||||
"""
|
||||
- signing_keys = self.get_signing_keys()
|
||||
- signing_key = self.match_kid(signing_keys, kid)
|
||||
-
|
||||
- if not signing_key:
|
||||
- # If no matching signing key from the jwk set, refresh the jwk set and try again.
|
||||
- signing_keys = self.get_signing_keys(refresh=True)
|
||||
+ with self._client_lock:
|
||||
+ signing_keys = self.get_signing_keys()
|
||||
signing_key = self.match_kid(signing_keys, kid)
|
||||
|
||||
if not signing_key:
|
||||
- raise PyJWKClientError(
|
||||
- f'Unable to find a signing key that matches: "{kid}"'
|
||||
+ cooling_down = (
|
||||
+ self.jwk_set_cache is not None
|
||||
+ and self._last_successful_fetch is not None
|
||||
+ and time.monotonic() - self._last_successful_fetch
|
||||
+ < self.cooldown_duration
|
||||
)
|
||||
+ if not cooling_down:
|
||||
+ signing_keys = self.get_signing_keys(refresh=True)
|
||||
+ self._last_successful_fetch = time.monotonic()
|
||||
+ signing_key = self.match_kid(signing_keys, kid)
|
||||
|
||||
- return signing_key
|
||||
+ if not signing_key:
|
||||
+ raise PyJWKClientError(
|
||||
+ f'Unable to find a signing key that matches: "{kid}"'
|
||||
+ )
|
||||
+
|
||||
+ return signing_key
|
||||
|
||||
def get_signing_key_from_jwt(self, token: str | bytes) -> PyJWK:
|
||||
"""Return the signing key for a JWT by reading its ``kid`` header.
|
||||
diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py
|
||||
index 49471ac..c66dfd7 100644
|
||||
--- a/tests/test_jwks_client.py
|
||||
+++ b/tests/test_jwks_client.py
|
||||
@@ -386,7 +386,7 @@ class TestPyJWKClient:
|
||||
|
||||
def test_get_jwt_set_refresh_cache(self) -> None:
|
||||
url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
|
||||
- jwks_client = PyJWKClient(url)
|
||||
+ jwks_client = PyJWKClient(url, cooldown_duration=0)
|
||||
|
||||
kid = "NEE1QURBOTM4MzI5RkFDNTYxOTU1MDg2ODgwQ0UzMTk1QjYyRkRFQw"
|
||||
|
||||
@@ -411,6 +411,145 @@ class TestPyJWKClient:
|
||||
):
|
||||
jwks_client.get_signing_key(kid)
|
||||
|
||||
+ def test_unknown_kid_refresh_is_cooled_down(self) -> None:
|
||||
+ url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
|
||||
+ kid = "unknown-kid"
|
||||
+ jwks_client = PyJWKClient(url, cooldown_duration=30)
|
||||
+
|
||||
+ with mock.patch("urllib.request.build_opener") as build_opener_mock:
|
||||
+ opener = mock.Mock()
|
||||
+ build_opener_mock.return_value = opener
|
||||
+ response = mock.Mock()
|
||||
+ response.__enter__ = mock.Mock(return_value=response)
|
||||
+ response.__exit__ = mock.Mock()
|
||||
+ response.read.return_value = json.dumps(RESPONSE_DATA_NO_MATCHING_KID)
|
||||
+ opener.open.return_value = response
|
||||
+
|
||||
+ for _ in range(2):
|
||||
+ with pytest.raises(PyJWKClientError, match="matches"):
|
||||
+ jwks_client.get_signing_key(kid)
|
||||
+
|
||||
+ assert opener.open.call_count == 1
|
||||
+
|
||||
+ def test_unknown_kid_refresh_runs_again_after_cooldown(self) -> None:
|
||||
+ url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
|
||||
+ kid = "unknown-kid"
|
||||
+ jwks_client = PyJWKClient(url, cooldown_duration=0.01)
|
||||
+
|
||||
+ with mock.patch("urllib.request.build_opener") as build_opener_mock:
|
||||
+ opener = mock.Mock()
|
||||
+ build_opener_mock.return_value = opener
|
||||
+ response = mock.Mock()
|
||||
+ response.__enter__ = mock.Mock(return_value=response)
|
||||
+ response.__exit__ = mock.Mock()
|
||||
+ response.read.return_value = json.dumps(RESPONSE_DATA_NO_MATCHING_KID)
|
||||
+ opener.open.return_value = response
|
||||
+
|
||||
+ clock = [0.0]
|
||||
+ with mock.patch(
|
||||
+ "jwt.jwks_client.time.monotonic", side_effect=lambda: clock[0]
|
||||
+ ):
|
||||
+ with pytest.raises(PyJWKClientError, match="matches"):
|
||||
+ jwks_client.get_signing_key(kid)
|
||||
+ clock[0] = 0.02
|
||||
+ with pytest.raises(PyJWKClientError, match="matches"):
|
||||
+ jwks_client.get_signing_key(kid)
|
||||
+
|
||||
+ assert opener.open.call_count == 2
|
||||
+
|
||||
+ def test_unknown_kid_refresh_serializes_concurrent_misses(self) -> None:
|
||||
+ url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
|
||||
+ kid = "unknown-kid"
|
||||
+ jwks_client = PyJWKClient(url, cooldown_duration=30)
|
||||
+ refresh_started = threading.Event()
|
||||
+ second_done = threading.Event()
|
||||
+ release_refresh = threading.Event()
|
||||
+
|
||||
+ with mock.patch("urllib.request.build_opener") as build_opener_mock:
|
||||
+ opener = mock.Mock()
|
||||
+ build_opener_mock.return_value = opener
|
||||
+ response = mock.Mock()
|
||||
+ response.__enter__ = mock.Mock(return_value=response)
|
||||
+ response.__exit__ = mock.Mock()
|
||||
+ response.read.return_value = json.dumps(RESPONSE_DATA_NO_MATCHING_KID)
|
||||
+
|
||||
+ opener.open.return_value = response
|
||||
+ jwks_client.get_jwk_set()
|
||||
+ jwks_client._last_successful_fetch = 0
|
||||
+
|
||||
+ def open_response(*args: object, **kwargs: object) -> mock.Mock:
|
||||
+ if opener.open.call_count == 1:
|
||||
+ refresh_started.set()
|
||||
+ release_refresh.wait(timeout=5)
|
||||
+ return response
|
||||
+
|
||||
+ opener.open.reset_mock()
|
||||
+ opener.open.side_effect = open_response
|
||||
+ errors: list[Exception] = []
|
||||
+
|
||||
+ def lookup() -> None:
|
||||
+ try:
|
||||
+ jwks_client.get_signing_key(kid)
|
||||
+ except PyJWKClientError as error:
|
||||
+ errors.append(error)
|
||||
+
|
||||
+ first = threading.Thread(target=lookup)
|
||||
+
|
||||
+ def second_lookup() -> None:
|
||||
+ lookup()
|
||||
+ second_done.set()
|
||||
+
|
||||
+ second = threading.Thread(target=second_lookup)
|
||||
+ with mock.patch("jwt.jwks_client.time.monotonic", return_value=31):
|
||||
+ first.start()
|
||||
+ assert refresh_started.wait(timeout=5)
|
||||
+ second.start()
|
||||
+ assert not second_done.wait(timeout=0.1)
|
||||
+ release_refresh.set()
|
||||
+ first.join(timeout=5)
|
||||
+ second.join(timeout=5)
|
||||
+
|
||||
+ assert len(errors) == 2
|
||||
+ assert not first.is_alive()
|
||||
+ assert not second.is_alive()
|
||||
+ assert opener.open.call_count == 1
|
||||
+
|
||||
+ def test_unknown_kid_refresh_ignores_cooldown_when_cache_disabled(self) -> None:
|
||||
+ url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
|
||||
+ kid = "unknown-kid"
|
||||
+ jwks_client = PyJWKClient(url, cache_jwk_set=False, cooldown_duration=30)
|
||||
+
|
||||
+ with mock.patch("urllib.request.build_opener") as build_opener_mock:
|
||||
+ opener = mock.Mock()
|
||||
+ build_opener_mock.return_value = opener
|
||||
+ response = mock.Mock()
|
||||
+ response.__enter__ = mock.Mock(return_value=response)
|
||||
+ response.__exit__ = mock.Mock()
|
||||
+ response.read.side_effect = [
|
||||
+ json.dumps(RESPONSE_DATA_NO_MATCHING_KID),
|
||||
+ json.dumps(RESPONSE_DATA_NO_MATCHING_KID),
|
||||
+ json.dumps(RESPONSE_DATA_NO_MATCHING_KID),
|
||||
+ json.dumps(RESPONSE_DATA_NO_MATCHING_KID),
|
||||
+ ]
|
||||
+ opener.open.return_value = response
|
||||
+
|
||||
+ for _ in range(2):
|
||||
+ with pytest.raises(PyJWKClientError, match="matches"):
|
||||
+ jwks_client.get_signing_key(kid)
|
||||
+
|
||||
+ assert opener.open.call_count == 4
|
||||
+
|
||||
+ @pytest.mark.parametrize(
|
||||
+ "cooldown_duration", [float("nan"), float("inf"), float("-inf")]
|
||||
+ )
|
||||
+ def test_unknown_kid_refresh_rejects_non_finite_cooldown(
|
||||
+ self, cooldown_duration: float
|
||||
+ ) -> None:
|
||||
+ url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
|
||||
+
|
||||
+ with pytest.raises(PyJWKClientError, match="Cooldown duration"):
|
||||
+ PyJWKClient(url, cooldown_duration=cooldown_duration)
|
||||
+
|
||||
def test_get_jwt_set_invalid_lifespan(self) -> None:
|
||||
url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
|
||||
|
||||
--
|
||||
2.35.6
|
||||
@@ -16,6 +16,7 @@ SRC_URI += "file://CVE-2026-48522.patch \
|
||||
file://CVE-2026-102267.patch \
|
||||
file://CVE-2026-102268_CVE-2026-102270.patch \
|
||||
file://CVE-2026-102271.patch \
|
||||
file://CVE-2026-101917.patch \
|
||||
"
|
||||
|
||||
SRC_URI[sha256sum] = "c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
|
||||
|
||||
Reference in new issue
Block a user