Skip to content

Bump PyJWT to 2.15.0 and cedarpy to 4.12.1 - #1732

Merged
np5 merged 2 commits into
mainfrom
20260926-bump-pyjwt-cedarpy
Sep 26, 2026
Merged

np5 merged 2 commits into
mainfrom
20260926-bump-pyjwt-cedarpy

Conversation

@np5

@np5 np5 commented Sep 26, 2026

Copy link
Copy Markdown
Member

Two dependency bumps, one commit each. Each commit can be reverted alone.

PyJWT 2.14.0 → 2.15.0

Security release. It closes two advisories against 2.14.0.

GHSA-42vr-xj54-vc7v (medium) is reachable without authentication.

  • PyJWKClient.get_signing_key_from_jwt decodes the token payload before the signature check.
  • On 2.14.0, a very deeply nested JSON payload makes json.loads raise RecursionError. This is not a PyJWTError, so zentral.utils.oidc.verify_jws does not catch it.
  • The OIDC API token issuer auth view (server/accounts/api_views.py) is AllowAny. One forged token of approximately 270 KB gets a 500.
  • On 2.15.0, the error is a DecodeError, and the view returns "Invalid token" with a 400.

GHSA-x33g-cr3x-6449 (medium) is not reachable. It is about OKP private JWKs. verify_jws uses only the public keys of the issuer JWKS.

Other changes in 2.15.0 that the JWKS of a configured issuer can reach:

  • A truncated JWKS response (http.client.HTTPException) is a PyJWKClientConnectionError, which verify_jws catches.
  • A JWK Set member that is not correct is skipped. It does not make the whole set fail.
  • An exp, nbf or iat claim that is a list, a dict or null raises a PyJWTError, not a TypeError. This check occurs after the signature check.

2.15.0 does not fix the same RecursionError on the JWKS response. Only the JWKS endpoint of an issuer that an administrator configured can send it, and the result is a 500, not a bypass.

New test

test_verify_jws_deeply_nested_payload sends a forged token with a deeply nested payload to verify_jws. It also checks that no JWKS fetch occurs.

  • On 2.14.0: error, RecursionError: Stack overflow.
  • On 2.15.0: pass.

On Python 3.14, json.loads raises RecursionError only when the C stack is full. 20,000 levels (the advisory example) parse without error. 100,000 levels fill the default 8 MB stack. The test uses 1,000,000 levels to keep a margin for a larger stack.

cedarpy 4.12.0 → 4.12.1

Same Cedar engine (v4.12.0). The only runtime change: the diagnostics of is_authorized and is_authorized_batch give the cause. For example, failed to build request: <cause> replaces failed to parse schema from request.

  • The decisions do not change. The is_authorized_partial output is the same byte for byte.
  • authorize_request_preview in server/pbac/cedar.py reads only whether the errors are empty, so it is not affected.

Cost

  • The text that _log_cedar_errors writes to the logs changes. A log search or alert on the old cedarpy text does not match after this change. No test matches on it.
  • The new test builds a token of approximately 2.7 MB and fills the C stack of the test process. It runs in less than 0.1 s.
  • The dependency metadata of both packages does not change, so no other pin moves.

Tests

  • pip check is clean in the rebuilt image.
  • Full suite, with the CI environment variables: 8934 tests, OK.
  • flake8 and ruff are clean.

🤖 Generated with Claude Code

np5 and others added 2 commits September 26, 2026 09:07
cedarpy 4.12.1 wraps the same Cedar engine as 4.12.0 (v4.12.0). The only
runtime change is in the diagnostics of is_authorized and
is_authorized_batch: they now give the cause. A request that does not build
reports "failed to build request: <cause>" instead of the flat "failed to
parse schema from request", and an entity parse error gets ": <cause>" at the
end.

The decisions do not change, and the is_authorized_partial output is the same
byte for byte. The errors-emptiness guard in authorize_request_preview in
server/pbac/cedar.py reads only whether the errors are empty, so it is not
affected. The text that _log_cedar_errors writes to the logs changes. No test
matches on the old text.

The dependency metadata does not change, so no other pin moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security release. 2.15.0 closes two advisories against 2.14.0.

GHSA-42vr-xj54-vc7v, medium, is reachable here without authentication.
PyJWKClient.get_signing_key_from_jwt decodes the token payload before it
looks up the kid, so before any signature check. On 2.14.0, a payload that is
valid JSON but very deeply nested makes json.loads raise RecursionError, and
RecursionError is not a PyJWTError. zentral.utils.oidc.verify_jws catches
only PyJWTError, so the error escapes. The OIDC API token issuer auth view in
server/accounts/api_views.py is AllowAny and gives the token to verify_jws,
so one forged token of approximately 270 KB gets a 500. 2.15.0 wraps the
error in DecodeError, and the view returns "Invalid token" with a 400.

The new test sends such a token to verify_jws. It fails with RecursionError on
2.14.0 and passes on 2.15.0. It also makes sure that no JWKS fetch occurs. On
Python 3.14, json.loads raises RecursionError only when the C stack is full,
not at a fixed depth. 20,000 levels, the depth in the advisory, parses
without error, and 100,000 levels fills the default 8 MB stack. The test uses
1,000,000 levels to keep a margin for a larger stack.

GHSA-x33g-cr3x-6449, medium, is not reachable: it is about OKP private JWKs
whose public x part does not agree with the private d part. verify_jws uses
only the public keys of the issuer JWKS.

Also in 2.15.0, and reachable from the JWKS of a configured issuer:
PyJWKClient wraps http.client.HTTPException, for example IncompleteRead from
a truncated response, in PyJWKClientConnectionError, which verify_jws
catches. A JWK Set member that is not correct is skipped, and does not make
the whole set fail. An exp, nbf or iat claim that is a list, a dict or null
raises a PyJWTError instead of a TypeError. That check occurs after the
signature check, so only the issuer can send such a claim.

2.15.0 does not fix the same RecursionError on the JWKS response.
PyJWKClient.fetch_data still catches only the network errors around
json.load. Only the JWKS endpoint of an issuer that an administrator
configured can send that response, and the result is a 500, not a bypass.

JWKSetCache now keeps the parsed PyJWKSet. verify_jws makes a new PyJWKClient
for each verification, so this does not change the behaviour here.

The dependency metadata is the same for 2.14.0 and 2.15.0, so no other pin
moves. The comment in the OIDC tests now points to the 2.15.0 line that opens
the JWKS URI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@soysauceconspiracy soysauceconspiracy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 36226727561

Coverage remained the same at 90.908%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 58114
Covered Lines: 52830
Line Coverage: 90.91%
Coverage Strength: 0.91 hits per line

💛 - Coveralls

@np5
np5 merged commit b7df908 into main Sep 26, 2026
11 checks passed
@np5
np5 deleted the 20260926-bump-pyjwt-cedarpy branch September 26, 2026 07:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants