Skip to content

Fix JWT authentication security issues#90

Merged
xispa merged 3 commits into
2.xfrom
fix/jwt-security-blockers
Jul 1, 2026
Merged

Fix JWT authentication security issues#90
xispa merged 3 commits into
2.xfrom
fix/jwt-security-blockers

Conversation

@ramonski

Copy link
Copy Markdown
Contributor

Description of the issue/feature this PR addresses

Fixes three security-critical issues in the JWT PAS plugin introduced in #88.

Current behavior before PR

  1. Anonymous DoS via unbounded keystorage writes. decode_token() called signing_secret(userid) on the verification path, which lazily wrote a new entry to the portal's OOBTree annotation for any userid an unauthenticated client put into a JWT userid claim. An attacker could spray tokens with random userids and force unbounded ZODB writes / ConflictError storms while never authenticating successfully.
  2. Unsafe peek call. jwt.decode(token, verify=False) used the deprecated verify= kwarg (removed in PyJWT 2.x — pinning to pyjwt<2.0.0 masks it for now) and did not constrain the algorithm. A forged alg: none token reached the verification path before being rejected on signature mismatch.
  3. Fragile Authorization header parsing. get_jwt_token() read request._auth (private API) and used auth.split()[-1] to extract the token, which silently picks the wrong segment when the header contains extra whitespace or unexpected components.

Desired behavior after PR is merged

  1. signing_secret() is split. The create-on-miss path (signing_secret) is reachable only from create_token() (i.e. /login). The verification path (authenticateCredentialsdecode_token → new get_signing_secret()) never mutates the keystorage. authenticateCredentials also looks up the claimed user via api.get_user() before reaching decode_token, so non-existent userids short-circuit immediately. An attacker submitting tokens with random userids causes zero ZODB writes.
  2. peek_userid() now calls jwt.decode(token, options={"verify_signature": False}, algorithms=["HS256"]). The options form is supported by both PyJWT 1.x and 2.x, and the explicit algorithms list rejects alg: none tokens at the peek step.
  3. get_jwt_token() uses the public request.getHeader("Authorization") API and auth[7:].strip() to extract the token after the Bearer prefix.

Tests

New doctest in bearer.rst asserts that a token claiming an unknown userid is rejected and leaves the keystorage untouched. Existing doctests (forged token, expired token, secret rotation, header/cookie/fallback extraction) continue to pass.

Run with:

bin/test test_doctests -t bearer
bin/test test_doctests -t login

--
I confirm I have tested the PR thoroughly and coded it according to PEP8 standards.

ramonski added 3 commits June 29, 2026 23:30
Three security-critical issues in the JWT PAS plugin:

1. decode_token() called signing_secret(userid), which lazily wrote a
   new secret to the portal OOBTree for any userid in the verified
   path. Anonymous attackers could spray tokens with random userids
   and force unbounded ZODB writes / ConflictError storms.

   Fix: split signing_secret() into two functions. get_signing_secret()
   never creates an entry and is used by the verification path;
   signing_secret() keeps create-on-miss for the /login token issuance
   path only. authenticateCredentials() also looks up the user via
   api.get_user() before reaching decode_token so non-existent userids
   short-circuit.

2. jwt.decode(token, verify=False) used the deprecated verify= kwarg
   (removed in PyJWT 2.x) and did not pin the algorithm, so a forged
   'alg: none' token could reach the verification path.

   Fix: switch to options={'verify_signature': False} and pin
   algorithms=['HS256'] in the peek call.

3. get_jwt_token() read request._auth (private API), used auth.split()
   which loses tokens with whitespace and is fragile under non-Bearer
   schemes.

   Fix: use request.getHeader('Authorization') and auth[7:].strip().
Zope/PAS may consume the Authorization header from the WSGI environ
before our extraction plugin runs, in which case getHeader() returns
None while request._auth still holds the raw value cached at request
init time.
@ramonski
ramonski requested a review from xispa June 29, 2026 22:05
@ramonski ramonski added the Security 🔒 Security concern label Jun 29, 2026

@xispa xispa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Excellent, thanks for fixing this vulnerability

@xispa
xispa merged commit 16bb48c into 2.x Jul 1, 2026
2 checks passed
@xispa
xispa deleted the fix/jwt-security-blockers branch July 1, 2026 20:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Security 🔒 Security concern

Development

Successfully merging this pull request may close these issues.

2 participants