Fix JWT authentication security issues#90
Merged
Conversation
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.
xispa
approved these changes
Jul 1, 2026
xispa
left a comment
Member
There was a problem hiding this comment.
Excellent, thanks for fixing this vulnerability
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
decode_token()calledsigning_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 JWTuseridclaim. An attacker could spray tokens with random userids and force unbounded ZODB writes / ConflictError storms while never authenticating successfully.jwt.decode(token, verify=False)used the deprecatedverify=kwarg (removed in PyJWT 2.x — pinning topyjwt<2.0.0masks it for now) and did not constrain the algorithm. A forgedalg: nonetoken reached the verification path before being rejected on signature mismatch.get_jwt_token()readrequest._auth(private API) and usedauth.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
signing_secret()is split. The create-on-miss path (signing_secret) is reachable only fromcreate_token()(i.e./login). The verification path (authenticateCredentials→decode_token→ newget_signing_secret()) never mutates the keystorage.authenticateCredentialsalso looks up the claimed user viaapi.get_user()before reachingdecode_token, so non-existent userids short-circuit immediately. An attacker submitting tokens with random userids causes zero ZODB writes.peek_userid()now callsjwt.decode(token, options={"verify_signature": False}, algorithms=["HS256"]). Theoptionsform is supported by both PyJWT 1.x and 2.x, and the explicitalgorithmslist rejectsalg: nonetokens at the peek step.get_jwt_token()uses the publicrequest.getHeader("Authorization")API andauth[7:].strip()to extract the token after theBearerprefix.Tests
New doctest in
bearer.rstasserts 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:
--
I confirm I have tested the PR thoroughly and coded it according to PEP8 standards.