diff mbox series

[meta-python,scarthgap,2/8] python3-pyjwt: fix CVE-2026-102267

Message ID 20261105045614.2677776-2-hthakar@cisco.com
State New
Headers show
Series [meta-python,scarthgap,1/8] python3-pyjwt: fix CVE-2026-102269 | expand

Commit Message

From: Hetvi Thakar <hthakar@cisco.com>

This patch applies the upstream fix as referenced in [2], using the
commit shown in [1].

Reject redirects when PyJWKClient fetches a JWKS to prevent requests
from following redirects to unintended destinations.

[1] https://github.com/jpadilla/pyjwt/commit/0a795b8e1f6ef08f634aa7086fc41cc6d5ce3e56
[2] https://github.com/jpadilla/pyjwt/security/advisories/GHSA-9v7f-9g4p-ffgj

Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
---
 .../python3-pyjwt/CVE-2026-102267.patch       | 223 ++++++++++++++++++
 .../python/python3-pyjwt_2.8.0.bb             |   1 +
 2 files changed, 224 insertions(+)
 create mode 100644 meta-python/recipes-devtools/python/python3-pyjwt/CVE-2026-102267.patch
diff mbox series

Patch

diff --git a/meta-python/recipes-devtools/python/python3-pyjwt/CVE-2026-102267.patch b/meta-python/recipes-devtools/python/python3-pyjwt/CVE-2026-102267.patch
new file mode 100644
index 0000000000..d83cd8ca59
--- /dev/null
+++ b/meta-python/recipes-devtools/python/python3-pyjwt/CVE-2026-102267.patch
@@ -0,0 +1,223 @@ 
+From 67ef104edd43a60ba87db0740364778eae796fe2 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Jos=C3=A9=20Padilla?= <jpadilla@users.noreply.github.com>
+Date: Wed, 14 Oct 2026 02:41:53 -0700
+Subject: [PATCH] Reject redirects in PyJWKClient fetches
+
+CVE: CVE-2026-102267
+Upstream-Status: Backport [https://github.com/jpadilla/pyjwt/commit/0a795b8e1f6ef08f634aa7086fc41cc6d5ce3e56]
+
+Backport Changes:
+- Used the existing typing.List import for the handlers annotation.
+- Omitted the mocked_http_error_response opener-mock conversion because
+  that helper is absent from the PyJWT 2.8.0 test suite.
+- Adapted the two SSL-context tests from live requests to opener mocks;
+  the 2.8.0 success test needs a local ssl_ctx and an explicit mocked
+  get_jwk_set call, and its failure test needs the certificate-error mock.
+
+Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
+---
+ jwt/jwks_client.py        | 21 +++++++--
+ tests/test_jwks_client.py | 96 ++++++++++++++++++++++++++++++++++-----
+ 2 files changed, 103 insertions(+), 14 deletions(-)
+
+diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py
+index 0e7d226..ab601a4 100644
+--- a/jwt/jwks_client.py
++++ b/jwt/jwks_client.py
+@@ -12,6 +12,19 @@ from .exceptions import PyJWKClientConnectionError, PyJWKClientError
+ from .jwk_set_cache import JWKSetCache
+ 
+ 
++class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
++    def redirect_request(
++        self,
++        req: urllib.request.Request,
++        fp: Any,
++        code: int,
++        msg: str,
++        headers: Any,
++        newurl: str,
++    ) -> None:
++        return None
++
++
+ class PyJWKClient:
+     def __init__(
+         self,
+@@ -61,9 +74,11 @@ class PyJWKClient:
+     def fetch_data(self) -> Any:
+         try:
+             r = urllib.request.Request(url=self.uri, headers=self.headers)
+-            with urllib.request.urlopen(
+-                r, timeout=self.timeout, context=self.ssl_context
+-            ) as response:
++            handlers: List[Any] = [_NoRedirectHandler()]
++            if self.ssl_context is not None:
++                handlers.append(urllib.request.HTTPSHandler(context=self.ssl_context))
++            opener = urllib.request.build_opener(*handlers)
++            with opener.open(r, timeout=self.timeout) as response:
+                 jwk_set = json.load(response)
+         except (URLError, TimeoutError) as e:
+             raise PyJWKClientConnectionError(
+diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py
+index 4a836f2..b3f2555 100644
+--- a/tests/test_jwks_client.py
++++ b/tests/test_jwks_client.py
+@@ -1,7 +1,9 @@
+ import contextlib
+ import json
+ import ssl
++import threading
+ import time
++from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+ from unittest import mock
+ from urllib.error import URLError
+ 
+@@ -9,6 +11,7 @@ import pytest
+ 
+ import jwt
+ from jwt import PyJWKClient
++from jwt.jwks_client import _NoRedirectHandler
+ from jwt.api_jwk import PyJWK
+ from jwt.exceptions import PyJWKClientConnectionError, PyJWKClientError
+ 
+@@ -47,7 +50,10 @@ RESPONSE_DATA_NO_MATCHING_KID = {
+ 
+ @contextlib.contextmanager
+ def mocked_success_response(data):
+-    with mock.patch("urllib.request.urlopen") as urlopen_mock:
++    with mock.patch("urllib.request.build_opener") as build_opener_mock:
++        opener = mock.Mock()
++        build_opener_mock.return_value = opener
++        urlopen_mock = opener.open
+         response = mock.Mock()
+         response.__enter__ = mock.Mock(return_value=response)
+         response.__exit__ = mock.Mock()
+@@ -58,7 +64,10 @@ def mocked_success_response(data):
+ 
+ @contextlib.contextmanager
+ def mocked_failed_response():
+-    with mock.patch("urllib.request.urlopen") as urlopen_mock:
++    with mock.patch("urllib.request.build_opener") as build_opener_mock:
++        opener = mock.Mock()
++        build_opener_mock.return_value = opener
++        urlopen_mock = opener.open
+         urlopen_mock.side_effect = URLError("Fail to process the request.")
+         yield urlopen_mock
+ 
+@@ -67,7 +76,10 @@ def mocked_failed_response():
+ def mocked_first_call_wrong_kid_second_call_correct_kid(
+     response_data_one, response_data_two
+ ):
+-    with mock.patch("urllib.request.urlopen") as urlopen_mock:
++    with mock.patch("urllib.request.build_opener") as build_opener_mock:
++        opener = mock.Mock()
++        build_opener_mock.return_value = opener
++        urlopen_mock = opener.open
+         response = mock.Mock()
+         response.__enter__ = mock.Mock(return_value=response)
+         response.__exit__ = mock.Mock()
+@@ -81,13 +93,60 @@ def mocked_first_call_wrong_kid_second_call_correct_kid(
+ 
+ @contextlib.contextmanager
+ def mocked_timeout():
+-    with mock.patch("urllib.request.urlopen") as urlopen_mock:
++    with mock.patch("urllib.request.build_opener") as build_opener_mock:
++        opener = mock.Mock()
++        build_opener_mock.return_value = opener
++        urlopen_mock = opener.open
+         urlopen_mock.side_effect = TimeoutError("timed out")
+         yield urlopen_mock
+ 
+ 
+ @crypto_required
+ class TestPyJWKClient:
++    def test_fetch_data_rejects_redirect_without_contacting_destination(self) -> None:
++        destination_requests = 0
++
++        class RedirectHandler(BaseHTTPRequestHandler):
++            def do_GET(self) -> None:  # noqa: N802 - stdlib handler API
++                nonlocal destination_requests
++                if self.path == "/start":
++                    self.send_response(302)
++                    self.send_header("Location", f"{base_url}/destination")
++                    self.end_headers()
++                else:
++                    destination_requests += 1
++                    self.send_response(200)
++                    self.end_headers()
++
++            def log_message(self, _format: str, *args: object) -> None:
++                return
++
++        server = ThreadingHTTPServer(("127.0.0.1", 0), RedirectHandler)
++        base_url = f"http://127.0.0.1:{server.server_port}"
++        thread = threading.Thread(target=server.serve_forever, daemon=True)
++        thread.start()
++        try:
++            client = PyJWKClient(
++                f"{base_url}/start", headers={"Authorization": "secret"}
++            )
++            with pytest.raises(PyJWKClientConnectionError):
++                client.fetch_data()
++            assert destination_requests == 0
++        finally:
++            server.shutdown()
++            server.server_close()
++            thread.join()
++
++    def test_redirect_handler_does_not_follow_redirects(self) -> None:
++        handler = _NoRedirectHandler()
++
++        assert (
++            handler.redirect_request(
++                mock.Mock(), mock.Mock(), 302, "Found", {}, "https://other.test"
++            )
++            is None
++        )
++
+     def test_fetch_data_forwards_headers_to_correct_url(self):
+         url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
+ 
+@@ -372,9 +431,22 @@ class TestPyJWKClient:
+ 
+     def test_get_jwt_set_sslcontext_default(self):
+         url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
+-        jwks_client = PyJWKClient(url, ssl_context=ssl.create_default_context())
+-
+-        jwk_set = jwks_client.get_jwk_set()
++        ssl_ctx = ssl.create_default_context()
++        jwks_client = PyJWKClient(url, ssl_context=ssl_ctx)
++
++        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_WITH_MATCHING_KID)
++            opener.open.return_value = response
++            jwk_set = jwks_client.get_jwk_set()
++            handlers = build_opener_mock.call_args.args
++            assert any(
++                getattr(handler, "_context", None) is ssl_ctx for handler in handlers
++            )
+ 
+         assert jwk_set is not None
+ 
+@@ -384,7 +456,9 @@ class TestPyJWKClient:
+             url, ssl_context=ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT)
+         )
+ 
+-        with pytest.raises(PyJWKClientError):
+-            jwks_client.get_jwk_set()
+-
+-        assert "Failed to get an expected error"
++        with mock.patch("urllib.request.build_opener") as build_opener_mock:
++            build_opener_mock.return_value.open.side_effect = URLError(
++                ssl.SSLCertVerificationError("certificate verify failed")
++            )
++            with pytest.raises(PyJWKClientError):
++                jwks_client.get_jwk_set()
+-- 
+2.35.6
diff --git a/meta-python/recipes-devtools/python/python3-pyjwt_2.8.0.bb b/meta-python/recipes-devtools/python/python3-pyjwt_2.8.0.bb
index 5a1dac4df6..fa03d1e616 100644
--- a/meta-python/recipes-devtools/python/python3-pyjwt_2.8.0.bb
+++ b/meta-python/recipes-devtools/python/python3-pyjwt_2.8.0.bb
@@ -13,6 +13,7 @@  SRC_URI += " \
     file://CVE-2026-48526.patch \
     file://CVE-2026-102269_p1.patch \
     file://CVE-2026-102269_p2.patch \
+    file://CVE-2026-102267.patch \
 "
 SRC_URI[sha256sum] = "57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"