Skip to content

Commit 3aef5e2

Browse files
committed
Fix JWT authentication security issues
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().
1 parent cbd6e35 commit 3aef5e2

3 files changed

Lines changed: 90 additions & 28 deletions

File tree

src/senaite/jsonapi/pas/plugin.py

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -89,36 +89,45 @@ def authenticateCredentials(self, credentials): # noqa camelCase
8989
return None
9090

9191
# Peek the payload (no signature check) to learn the userid the
92-
# token claims to belong to
92+
# token claims to belong to. The unverified userid is only used
93+
# below as a key into the keystorage; it is never trusted on its
94+
# own (see peek_userid docstring).
9395
userid = peek_userid(token)
9496
if not userid:
9597
return None
9698

97-
# Verify signature and expiration with that user's secret
99+
# Look up the user *before* touching the keystorage. This avoids
100+
# an anonymous DoS where an attacker sprays tokens with random
101+
# userids and forces the plugin to create a new signing-secret
102+
# entry on the portal's OOBTree annotation for each one.
103+
user = api.get_user(userid)
104+
if not user:
105+
return None
106+
107+
# Verify signature and expiration with that user's existing
108+
# secret. decode_token returns None if no secret is stored for
109+
# the user yet (i.e. the user has never logged in via /login).
98110
payload = decode_token(token, userid)
99111
if not payload:
100112
return None
101113

102-
# Defensive: ensure the verified payload still names the same user
114+
# Defensive: ensure the verified payload still names the same
115+
# user as the (now signature-verified) claim.
103116
if api.to_utf8(payload.get("userid"), default=None) != userid:
104117
return None
105118

106-
# Make sure the user still exists
107-
user = api.get_user(userid)
108-
if not user:
109-
return None
110-
111119
return userid, userid
112120

113121

114122
def get_jwt_token(request):
115123
"""Extracts the JWT token from the request, in this order of preference:
116124
Authorization: Bearer header, "token" cookie, X-JWT-Auth-Token header.
117125
"""
118-
# Read from Authorization header
119-
auth = request._auth # noqa
120-
if auth and auth[:7].lower() == "bearer ":
121-
return auth.split()[-1]
126+
# Read from Authorization header. Use the public getHeader() API
127+
# rather than the private request._auth attribute.
128+
auth = request.getHeader("Authorization") or ""
129+
if auth[:7].lower() == "bearer ":
130+
return auth[7:].strip()
122131

123132
# Read from cookie
124133
token = request.cookies.get(JWT_COOKIE_ID)
@@ -160,7 +169,7 @@ def signing_secret(userid):
160169

161170
def rotate_secret(userid):
162171
"""Discards the signing secret for the given userid, so a new one is
163-
generated on the next call to ``signing_secret``. All previously
172+
generated on the next call to `signing_secret`. All previously
164173
issued tokens for the user are invalidated.
165174
"""
166175
userid = api.to_utf8(userid, default=None)
@@ -180,36 +189,68 @@ def timestamp(seconds=0, minutes=0, hours=0, days=0):
180189

181190

182191
def peek_userid(token):
183-
"""Returns the ``userid`` claim of the given token without verifying
192+
"""Returns the `userid` claim of the given token without verifying
184193
the signature, or None if the token cannot be decoded.
185194
186195
Decoding the token without verifying its signature is safe in this
187-
context: the returned ``userid`` is only used to look up *that
196+
context: the returned `userid` is only used to look up *that
188197
user's* signing secret, which is then used to verify the signature
189-
in ``decode_token``. A forged token that claims to belong to user
198+
in `decode_token`. A forged token that claims to belong to user
190199
X but was signed with anything other than X's real secret fails
191-
signature verification and is rejected. The unverified ``userid``
200+
signature verification and is rejected. The unverified `userid`
192201
is never trusted on its own.
193202
"""
194203
token = api.to_utf8(token, default=None)
195204
if not token:
196205
return None
197206
try:
198-
payload = jwt.decode(token, verify=False)
207+
# Disable signature verification at peek time, but still
208+
# require HS256 as the algorithm so a forged "alg": "none"
209+
# token cannot reach signature verification with no secret.
210+
# The `verify=False` kwarg was removed in PyJWT 2.x; the
211+
# `options` form is supported by both 1.x and 2.x.
212+
payload = jwt.decode(
213+
token, options={"verify_signature": False},
214+
algorithms=["HS256"],
215+
)
199216
except (ValueError, TypeError, jwt.InvalidTokenError):
200217
return None
201218
return api.to_utf8(payload.get("userid"), default=None)
202219

203220

221+
def get_signing_secret(userid):
222+
"""Returns the existing signing secret for `userid` or None.
223+
224+
Unlike `signing_secret`, this never creates a new secret entry,
225+
so it is safe to call with an unverified userid claim (e.g. during
226+
token verification).
227+
"""
228+
userid = api.to_utf8(userid, default=None)
229+
if not userid:
230+
return None
231+
data = get_keystorage().get(userid)
232+
if not data:
233+
return None
234+
return data.get("key")
235+
236+
204237
def decode_token(token, userid):
205-
"""Decodes the given JWT, verifies the signature with ``userid``'s
238+
"""Decodes the given JWT, verifies the signature with `userid`'s
206239
secret and checks the expiration. Returns the payload or None.
240+
241+
Returns None if no signing secret exists for `userid` yet; the
242+
keystorage entry is only created by `create_token` (called from
243+
`/login`), never by the verification path. This prevents an
244+
anonymous attacker from forcing the portal to create one
245+
keystorage entry per token claim.
207246
"""
208247
token = api.to_utf8(token, default=None)
209248
if not token:
210249
return None
250+
secret = get_signing_secret(userid)
251+
if not secret:
252+
return None
211253
try:
212-
secret = signing_secret(userid)
213254
return jwt.decode(token, secret, algorithms=["HS256"])
214255
except (ValueError, TypeError, jwt.InvalidTokenError):
215256
return None

src/senaite/jsonapi/tests/doctests/bearer.rst

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ once the user is authenticated):
6767
>>> isinstance(token, basestring) and len(token) > 0
6868
True
6969

70-
A fresh browser carrying that token in the ``Authorization: Bearer``
70+
A fresh browser carrying that token in the `Authorization: Bearer`
7171
header is authenticated by the JWT PAS plugin:
7272

7373
>>> is_authenticated(with_bearer(fresh_browser(), token))
@@ -77,9 +77,9 @@ header is authenticated by the JWT PAS plugin:
7777
The X-JWT-Auth-Token fallback header authenticates the request
7878
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
7979

80-
Clients that cannot set the ``Authorization`` header (some embedded
80+
Clients that cannot set the `Authorization` header (some embedded
8181
HTTP libraries, browser extensions, etc.) can use the
82-
``X-JWT-Auth-Token`` fallback header instead:
82+
`X-JWT-Auth-Token` fallback header instead:
8383

8484
>>> fallback = fresh_browser()
8585
>>> fallback.addHeader("X-JWT-Auth-Token", token.encode("ascii"))
@@ -110,10 +110,31 @@ verification:
110110
False
111111

112112

113+
A token for an unknown user does not create a keystorage entry
114+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
115+
116+
Tokens that claim to belong to a user who does not exist must be
117+
rejected without touching the per-user keystorage. Otherwise an
118+
anonymous attacker can force unbounded ZODB writes by spraying
119+
tokens with random userids.
120+
121+
>>> from senaite.jsonapi.pas.plugin import get_keystorage
122+
>>> before = set(get_keystorage().keys())
123+
124+
>>> attacker = pyjwt.encode(
125+
... {"userid": "nobody-12345", "exp": timestamp(seconds=3600)},
126+
... "attacker-secret", algorithm="HS256")
127+
>>> is_authenticated(with_bearer(fresh_browser(), attacker))
128+
False
129+
130+
>>> set(get_keystorage().keys()) == before
131+
True
132+
133+
113134
An expired token does not authenticate
114135
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
115136

116-
A token whose ``exp`` claim lies in the past is rejected by PyJWT:
137+
A token whose `exp` claim lies in the past is rejected by PyJWT:
117138

118139
>>> expired_token = create_token(TEST_USER_ID, exp=timestamp(seconds=-60))
119140
>>> transaction.commit()
@@ -124,7 +145,7 @@ A token whose ``exp`` claim lies in the past is rejected by PyJWT:
124145
Rotating the user's secret revokes existing tokens
125146
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
126147

127-
After ``rotate_secret``, the previously-issued token no longer
148+
After `rotate_secret`, the previously-issued token no longer
128149
verifies:
129150

130151
>>> rotate_secret(TEST_USER_ID)

src/senaite/jsonapi/tests/doctests/login.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Issue a token via /login
5050
~~~~~~~~~~~~~~~~~~~~~~~~
5151

5252
An already-authenticated user calls /login and obtains a JWT in the
53-
response body. The setup helper ``self.getBrowser()`` returns a browser
53+
response body. The setup helper `self.getBrowser()` returns a browser
5454
that has a Plone session cookie, so the request is authenticated by
5555
the cookie auth plugin before the route runs:
5656

@@ -69,9 +69,9 @@ the cookie auth plugin before the route runs:
6969

7070
The response also sets the JWT as an HttpOnly, Secure cookie. The
7171
test browser drives the API over plain HTTP, so the Secure cookie is
72-
not retained by ``browser.cookies``; the assertion below would only
72+
not retained by `browser.cookies`; the assertion below would only
7373
hold over HTTPS. Clients running on HTTP must therefore use the
74-
``Authorization: Bearer`` header (covered next).
74+
`Authorization: Bearer` header (covered next).
7575

7676
The Set-Cookie header still carries the expected security attributes
7777
even when the browser drops the cookie itself:

0 commit comments

Comments
 (0)