jwtlint 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jwtlint-0.2.0/LICENSE +21 -0
- jwtlint-0.2.0/PKG-INFO +149 -0
- jwtlint-0.2.0/README.md +125 -0
- jwtlint-0.2.0/jwtlint/__init__.py +3 -0
- jwtlint-0.2.0/jwtlint/checks/__init__.py +31 -0
- jwtlint-0.2.0/jwtlint/checks/alg_confusion.py +80 -0
- jwtlint-0.2.0/jwtlint/checks/alg_none.py +60 -0
- jwtlint-0.2.0/jwtlint/checks/base.py +30 -0
- jwtlint-0.2.0/jwtlint/checks/claims.py +146 -0
- jwtlint-0.2.0/jwtlint/checks/crack.py +100 -0
- jwtlint-0.2.0/jwtlint/checks/header_injection.py +137 -0
- jwtlint-0.2.0/jwtlint/checks/sensitive.py +101 -0
- jwtlint-0.2.0/jwtlint/cli.py +152 -0
- jwtlint-0.2.0/jwtlint/parser.py +101 -0
- jwtlint-0.2.0/jwtlint/wordlist.py +63 -0
- jwtlint-0.2.0/jwtlint.egg-info/PKG-INFO +149 -0
- jwtlint-0.2.0/jwtlint.egg-info/SOURCES.txt +23 -0
- jwtlint-0.2.0/jwtlint.egg-info/dependency_links.txt +1 -0
- jwtlint-0.2.0/jwtlint.egg-info/entry_points.txt +2 -0
- jwtlint-0.2.0/jwtlint.egg-info/requires.txt +2 -0
- jwtlint-0.2.0/jwtlint.egg-info/top_level.txt +1 -0
- jwtlint-0.2.0/pyproject.toml +37 -0
- jwtlint-0.2.0/setup.cfg +4 -0
- jwtlint-0.2.0/tests/test_jwtlint.py +247 -0
- jwtlint-0.2.0/tests/test_new_checks.py +150 -0
jwtlint-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Baran Ayaztaş
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
jwtlint-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jwtlint
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Offline static security analysis for JWTs.
|
|
5
|
+
Author: Baran Ayaztas
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ReazGan/jwtlint
|
|
8
|
+
Project-URL: Issues, https://github.com/ReazGan/jwtlint/issues
|
|
9
|
+
Keywords: jwt,security,pentest,bug-bounty,appsec,static-analysis,jws
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Information Technology
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: click>=8.1
|
|
22
|
+
Requires-Dist: rich>=13.7
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# jwtlint
|
|
26
|
+
|
|
27
|
+
[](https://github.com/ReazGan/jwtlint/actions/workflows/ci.yml)
|
|
28
|
+
[](https://pypi.org/project/jwtlint/)
|
|
29
|
+
|
|
30
|
+
Offline static security analysis for JWTs (RFC 7519 / RFC 7515). Paste a
|
|
31
|
+
token from Burp, a log line or an `Authorization` header and get a list of
|
|
32
|
+
what's wrong with it. Parses the three base64url segments itself, no JWT
|
|
33
|
+
library in the analysis path, no network calls.
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pip install jwtlint
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
or from source:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
git clone https://github.com/ReazGan/jwtlint
|
|
47
|
+
cd jwtlint
|
|
48
|
+
pip install -e .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
$ jwtlint eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwiaWF0IjoxNzg3MjY5OTY5LCJleHAiOjE3ODcyNzM1Njl9.0RVnIJ29GLfYPK3YfZNbLuhf_19F4nI71aeg0ZvZSr4
|
|
55
|
+
header
|
|
56
|
+
{
|
|
57
|
+
"alg": "HS256",
|
|
58
|
+
"typ": "JWT"
|
|
59
|
+
}
|
|
60
|
+
payload
|
|
61
|
+
{
|
|
62
|
+
"exp": 1787273569,
|
|
63
|
+
"iat": 1787269969,
|
|
64
|
+
"sub": "user-42"
|
|
65
|
+
}
|
|
66
|
+
jwtlint findings
|
|
67
|
+
┏━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━┓
|
|
68
|
+
┃ sev ┃ check ┃ finding ┃
|
|
69
|
+
┡━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━┩
|
|
70
|
+
└──────────┴───────┴─────────┘
|
|
71
|
+
|
|
72
|
+
0 findings
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
An `alg: none` forgery attempt (see the screenshot above for the full run):
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
$ jwtlint eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0.eyJzdWIiOiAiYXR0YWNrZXIiLCAiYWRtaW4iOiB0cnVlLCAiaWF0IjogMTcwMDAwMDAwMH0.
|
|
79
|
+
critical alg-none alg: 'none' — unsigned/forgeable token
|
|
80
|
+
high claims No exp (expiration) claim
|
|
81
|
+
|
|
82
|
+
2 findings — critical: 1 high: 1
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Other flags:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
jwtlint --file token.txt # read the token from a file instead of argv
|
|
89
|
+
pbpaste | jwtlint # read from stdin ("Bearer ..." prefix is fine)
|
|
90
|
+
jwtlint <token> --expect-alg RS256 # flag RS-to-HS confusion if the token uses HS*
|
|
91
|
+
jwtlint <token> --crack # try the built-in weak-secret wordlist
|
|
92
|
+
jwtlint <token> --crack wordlist.txt # try a custom wordlist
|
|
93
|
+
jwtlint <token> --json # findings as JSON instead of a table
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Exit code is `1` if any finding is `critical` or `high` — usable as a CI gate.
|
|
97
|
+
Malformed input (wrong segment count, bad base64url, bad JSON) exits `2` with
|
|
98
|
+
a plain error message, not a stack trace.
|
|
99
|
+
|
|
100
|
+
## Checks
|
|
101
|
+
|
|
102
|
+
- **alg-none** — header specifies `alg: none` (case-insensitively — `None`,
|
|
103
|
+
`NONE`, etc. are all caught), the classic unsigned-token forgery.
|
|
104
|
+
- **alg-confusion** — with `--expect-alg RS256` (or any RS/ES/PS algorithm),
|
|
105
|
+
flags a token that instead uses HS256/384/512 as a possible RS-to-HS key
|
|
106
|
+
confusion attack: a verifier that feeds its RSA/EC public key into HMAC
|
|
107
|
+
verification whenever the header says `alg: HS*` can be tricked into
|
|
108
|
+
accepting a token forged with that (public, therefore attacker-known) key
|
|
109
|
+
as the HMAC secret.
|
|
110
|
+
- **header-injection** — key-selection headers the attacker controls:
|
|
111
|
+
`jku` / `x5u` (verifier fetches the key from a URL, also an SSRF vector),
|
|
112
|
+
an embedded `jwk` (CVE-2018-0114 style self-signed tokens), an embedded
|
|
113
|
+
`x5c` chain, and `kid` values that look like path traversal
|
|
114
|
+
(`../../dev/null`), SQL or shell injection.
|
|
115
|
+
- **sensitive-data** — payload claims that look like passwords, API keys,
|
|
116
|
+
session ids or personal data (national id, IBAN, phone, card numbers with
|
|
117
|
+
a Luhn check). Payloads are base64url, not encrypted: anyone holding the
|
|
118
|
+
token can read them.
|
|
119
|
+
- **claims** — missing `exp` (high, token never expires), an already-expired
|
|
120
|
+
`exp` (info, just noted), missing `iat` (low), `nbf` earlier than `iat`
|
|
121
|
+
(low), and unreasonably long-lived tokens — `exp - iat` over a year by
|
|
122
|
+
default, configurable with `--max-lifetime-days`.
|
|
123
|
+
- **hmac-crack** (`--crack`, opt-in, fully offline) — for HS256/384/512
|
|
124
|
+
tokens, recomputes the HMAC signature for each line of a wordlist and
|
|
125
|
+
compares against the token's actual signature. Ships a built-in list of
|
|
126
|
+
~50 common weak secrets used by default when `--crack` is passed with no
|
|
127
|
+
file.
|
|
128
|
+
|
|
129
|
+
## In CI
|
|
130
|
+
|
|
131
|
+
Exit code `1` on critical/high makes it a one-line gate, e.g. to make sure
|
|
132
|
+
the tokens your test suite issues never carry secrets or skip `exp`:
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
- run: pip install jwtlint
|
|
136
|
+
- run: python scripts/issue_test_token.py | jwtlint
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Scope
|
|
140
|
+
|
|
141
|
+
A lean, non-interactive, single-purpose static analyzer — decode a token,
|
|
142
|
+
report what's wrong with it, exit. It doesn't talk to a server, doesn't try
|
|
143
|
+
exploits, and doesn't cover every JWT attack technique.
|
|
144
|
+
[`ticarpi/jwt_tool`](https://github.com/ticarpi/jwt_tool) is the heavier
|
|
145
|
+
reference tool if you need full interactive exploitation tooling.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
jwtlint-0.2.0/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# jwtlint
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ReazGan/jwtlint/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/jwtlint/)
|
|
5
|
+
|
|
6
|
+
Offline static security analysis for JWTs (RFC 7519 / RFC 7515). Paste a
|
|
7
|
+
token from Burp, a log line or an `Authorization` header and get a list of
|
|
8
|
+
what's wrong with it. Parses the three base64url segments itself, no JWT
|
|
9
|
+
library in the analysis path, no network calls.
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
pip install jwtlint
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
or from source:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
git clone https://github.com/ReazGan/jwtlint
|
|
23
|
+
cd jwtlint
|
|
24
|
+
pip install -e .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
$ jwtlint eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwiaWF0IjoxNzg3MjY5OTY5LCJleHAiOjE3ODcyNzM1Njl9.0RVnIJ29GLfYPK3YfZNbLuhf_19F4nI71aeg0ZvZSr4
|
|
31
|
+
header
|
|
32
|
+
{
|
|
33
|
+
"alg": "HS256",
|
|
34
|
+
"typ": "JWT"
|
|
35
|
+
}
|
|
36
|
+
payload
|
|
37
|
+
{
|
|
38
|
+
"exp": 1787273569,
|
|
39
|
+
"iat": 1787269969,
|
|
40
|
+
"sub": "user-42"
|
|
41
|
+
}
|
|
42
|
+
jwtlint findings
|
|
43
|
+
┏━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━┓
|
|
44
|
+
┃ sev ┃ check ┃ finding ┃
|
|
45
|
+
┡━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━┩
|
|
46
|
+
└──────────┴───────┴─────────┘
|
|
47
|
+
|
|
48
|
+
0 findings
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
An `alg: none` forgery attempt (see the screenshot above for the full run):
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
$ jwtlint eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0.eyJzdWIiOiAiYXR0YWNrZXIiLCAiYWRtaW4iOiB0cnVlLCAiaWF0IjogMTcwMDAwMDAwMH0.
|
|
55
|
+
critical alg-none alg: 'none' — unsigned/forgeable token
|
|
56
|
+
high claims No exp (expiration) claim
|
|
57
|
+
|
|
58
|
+
2 findings — critical: 1 high: 1
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Other flags:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
jwtlint --file token.txt # read the token from a file instead of argv
|
|
65
|
+
pbpaste | jwtlint # read from stdin ("Bearer ..." prefix is fine)
|
|
66
|
+
jwtlint <token> --expect-alg RS256 # flag RS-to-HS confusion if the token uses HS*
|
|
67
|
+
jwtlint <token> --crack # try the built-in weak-secret wordlist
|
|
68
|
+
jwtlint <token> --crack wordlist.txt # try a custom wordlist
|
|
69
|
+
jwtlint <token> --json # findings as JSON instead of a table
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Exit code is `1` if any finding is `critical` or `high` — usable as a CI gate.
|
|
73
|
+
Malformed input (wrong segment count, bad base64url, bad JSON) exits `2` with
|
|
74
|
+
a plain error message, not a stack trace.
|
|
75
|
+
|
|
76
|
+
## Checks
|
|
77
|
+
|
|
78
|
+
- **alg-none** — header specifies `alg: none` (case-insensitively — `None`,
|
|
79
|
+
`NONE`, etc. are all caught), the classic unsigned-token forgery.
|
|
80
|
+
- **alg-confusion** — with `--expect-alg RS256` (or any RS/ES/PS algorithm),
|
|
81
|
+
flags a token that instead uses HS256/384/512 as a possible RS-to-HS key
|
|
82
|
+
confusion attack: a verifier that feeds its RSA/EC public key into HMAC
|
|
83
|
+
verification whenever the header says `alg: HS*` can be tricked into
|
|
84
|
+
accepting a token forged with that (public, therefore attacker-known) key
|
|
85
|
+
as the HMAC secret.
|
|
86
|
+
- **header-injection** — key-selection headers the attacker controls:
|
|
87
|
+
`jku` / `x5u` (verifier fetches the key from a URL, also an SSRF vector),
|
|
88
|
+
an embedded `jwk` (CVE-2018-0114 style self-signed tokens), an embedded
|
|
89
|
+
`x5c` chain, and `kid` values that look like path traversal
|
|
90
|
+
(`../../dev/null`), SQL or shell injection.
|
|
91
|
+
- **sensitive-data** — payload claims that look like passwords, API keys,
|
|
92
|
+
session ids or personal data (national id, IBAN, phone, card numbers with
|
|
93
|
+
a Luhn check). Payloads are base64url, not encrypted: anyone holding the
|
|
94
|
+
token can read them.
|
|
95
|
+
- **claims** — missing `exp` (high, token never expires), an already-expired
|
|
96
|
+
`exp` (info, just noted), missing `iat` (low), `nbf` earlier than `iat`
|
|
97
|
+
(low), and unreasonably long-lived tokens — `exp - iat` over a year by
|
|
98
|
+
default, configurable with `--max-lifetime-days`.
|
|
99
|
+
- **hmac-crack** (`--crack`, opt-in, fully offline) — for HS256/384/512
|
|
100
|
+
tokens, recomputes the HMAC signature for each line of a wordlist and
|
|
101
|
+
compares against the token's actual signature. Ships a built-in list of
|
|
102
|
+
~50 common weak secrets used by default when `--crack` is passed with no
|
|
103
|
+
file.
|
|
104
|
+
|
|
105
|
+
## In CI
|
|
106
|
+
|
|
107
|
+
Exit code `1` on critical/high makes it a one-line gate, e.g. to make sure
|
|
108
|
+
the tokens your test suite issues never carry secrets or skip `exp`:
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
- run: pip install jwtlint
|
|
112
|
+
- run: python scripts/issue_test_token.py | jwtlint
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Scope
|
|
116
|
+
|
|
117
|
+
A lean, non-interactive, single-purpose static analyzer — decode a token,
|
|
118
|
+
report what's wrong with it, exit. It doesn't talk to a server, doesn't try
|
|
119
|
+
exploits, and doesn't cover every JWT attack technique.
|
|
120
|
+
[`ticarpi/jwt_tool`](https://github.com/ticarpi/jwt_tool) is the heavier
|
|
121
|
+
reference tool if you need full interactive exploitation tooling.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Static analysis checks run against a parsed JWT.
|
|
2
|
+
|
|
3
|
+
Each check module exposes a `check(parsed, **options) -> list[Finding]`
|
|
4
|
+
function. `run_all` wires them together in a fixed order.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from jwtlint.checks import alg_confusion, alg_none, claims, crack, header_injection, sensitive
|
|
10
|
+
from jwtlint.checks.base import Finding
|
|
11
|
+
from jwtlint.parser import ParsedToken
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_all(
|
|
15
|
+
parsed: ParsedToken,
|
|
16
|
+
expect_alg: str | None = None,
|
|
17
|
+
max_lifetime_seconds: int | None = None,
|
|
18
|
+
crack_wordlist: list[str] | None = None,
|
|
19
|
+
) -> list[dict]:
|
|
20
|
+
findings: list[Finding] = []
|
|
21
|
+
|
|
22
|
+
findings.extend(alg_none.check(parsed))
|
|
23
|
+
findings.extend(alg_confusion.check(parsed, expect_alg=expect_alg))
|
|
24
|
+
findings.extend(header_injection.check(parsed))
|
|
25
|
+
findings.extend(claims.check(parsed, max_lifetime_seconds=max_lifetime_seconds))
|
|
26
|
+
findings.extend(sensitive.check(parsed))
|
|
27
|
+
|
|
28
|
+
if crack_wordlist is not None:
|
|
29
|
+
findings.extend(crack.check(parsed, wordlist=crack_wordlist))
|
|
30
|
+
|
|
31
|
+
return [f.to_dict() for f in findings]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""RS-to-HS algorithm confusion check.
|
|
2
|
+
|
|
3
|
+
A static analyzer looking only at the token can't know what algorithm the
|
|
4
|
+
server actually expects — that's server-side configuration, not something
|
|
5
|
+
encoded in the token itself. `--expect-alg` lets the caller supply that
|
|
6
|
+
context (e.g. "I know this service is configured for RS256").
|
|
7
|
+
|
|
8
|
+
The attack: if a server verifies RS256/ES256 tokens using its RSA/EC
|
|
9
|
+
*public* key, and an attacker submits an HS256 token instead, some JWT
|
|
10
|
+
libraries will happily use whatever key material they're handed as the
|
|
11
|
+
HMAC secret. Since the RSA public key is, by definition, public, the
|
|
12
|
+
attacker can compute a valid HMAC signature over a forged token using the
|
|
13
|
+
public key as the secret — and the server verifies it as authentic. This
|
|
14
|
+
only matters if the verifier is naive enough to trust the `alg` from the
|
|
15
|
+
attacker-controlled header instead of pinning the expected algorithm.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from jwtlint.checks.base import Finding
|
|
21
|
+
from jwtlint.parser import ParsedToken
|
|
22
|
+
|
|
23
|
+
CHECK_NAME = "alg-confusion"
|
|
24
|
+
|
|
25
|
+
_HMAC_ALGS = {"HS256", "HS384", "HS512"}
|
|
26
|
+
_ASYMMETRIC_ALGS = {"RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check(parsed: ParsedToken, expect_alg: str | None = None) -> list[Finding]:
|
|
30
|
+
if not expect_alg:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
alg = parsed.header.get("alg")
|
|
34
|
+
if not isinstance(alg, str):
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
expect_alg_upper = expect_alg.upper()
|
|
38
|
+
alg_upper = alg.upper()
|
|
39
|
+
|
|
40
|
+
if alg_upper == expect_alg_upper:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
if alg_upper in _HMAC_ALGS and expect_alg_upper in _ASYMMETRIC_ALGS:
|
|
44
|
+
return [
|
|
45
|
+
Finding(
|
|
46
|
+
check=CHECK_NAME,
|
|
47
|
+
title=f"algorithm confusion risk: token uses {alg}, server expects {expect_alg}",
|
|
48
|
+
description=(
|
|
49
|
+
f"This token's header specifies alg={alg} (HMAC), but the "
|
|
50
|
+
f"server was told to expect {expect_alg} (RSA/EC, asymmetric). "
|
|
51
|
+
"This is the classic RS-to-HS confusion attack: if the "
|
|
52
|
+
"verifier passes its RSA/EC *public* key into an HMAC "
|
|
53
|
+
"verification routine whenever the header says alg=HS*, an "
|
|
54
|
+
"attacker can sign a forged token with HMAC-SHA256 using the "
|
|
55
|
+
"public key (which is, by definition, known to the attacker) "
|
|
56
|
+
"as the secret, and the server will treat it as validly "
|
|
57
|
+
"signed. A safe verifier must pin the expected algorithm "
|
|
58
|
+
"server-side and reject any token whose header claims a "
|
|
59
|
+
"different one, rather than trusting alg from attacker-"
|
|
60
|
+
"controlled input."
|
|
61
|
+
),
|
|
62
|
+
severity="critical",
|
|
63
|
+
evidence={"token_alg": alg, "expected_alg": expect_alg},
|
|
64
|
+
)
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
return [
|
|
68
|
+
Finding(
|
|
69
|
+
check=CHECK_NAME,
|
|
70
|
+
title=f"algorithm mismatch: token uses {alg}, server expects {expect_alg}",
|
|
71
|
+
description=(
|
|
72
|
+
f"The token's alg ({alg}) does not match the expected algorithm "
|
|
73
|
+
f"({expect_alg}). A verifier that doesn't strictly pin/allowlist "
|
|
74
|
+
"the algorithm may accept tokens signed under a weaker or "
|
|
75
|
+
"unintended scheme."
|
|
76
|
+
),
|
|
77
|
+
severity="medium",
|
|
78
|
+
evidence={"token_alg": alg, "expected_alg": expect_alg},
|
|
79
|
+
)
|
|
80
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""`alg: none` check (CVE-class: JWT algorithm-none forgery).
|
|
2
|
+
|
|
3
|
+
Some JWT libraries historically honored `"alg": "none"` (or accepted it
|
|
4
|
+
case-insensitively — `"None"`, `"NONE"`, `"nOnE"`) and skipped signature
|
|
5
|
+
verification entirely. An attacker who can set the header can then forge
|
|
6
|
+
any payload with an empty signature segment. This is check #1 in basically
|
|
7
|
+
every JWT security write-up because it's still found in the wild in custom
|
|
8
|
+
or misconfigured verifiers.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from jwtlint.checks.base import Finding
|
|
14
|
+
from jwtlint.parser import ParsedToken
|
|
15
|
+
|
|
16
|
+
CHECK_NAME = "alg-none"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def check(parsed: ParsedToken) -> list[Finding]:
|
|
20
|
+
alg = parsed.header.get("alg")
|
|
21
|
+
if not isinstance(alg, str):
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
if alg.lower() != "none":
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
findings = [
|
|
28
|
+
Finding(
|
|
29
|
+
check=CHECK_NAME,
|
|
30
|
+
title=f"alg: {alg!r} — unsigned/forgeable token",
|
|
31
|
+
description=(
|
|
32
|
+
"The header sets alg to a case-variant of 'none'. Libraries that "
|
|
33
|
+
"compare this case-insensitively (or that special-case 'none' "
|
|
34
|
+
"without validating the signature segment) will accept this token "
|
|
35
|
+
"with any payload and no valid signature — a classic auth bypass. "
|
|
36
|
+
"A safe verifier must reject 'none' outright and only accept an "
|
|
37
|
+
"algorithm it was explicitly configured to expect."
|
|
38
|
+
),
|
|
39
|
+
severity="critical",
|
|
40
|
+
evidence={"alg": alg},
|
|
41
|
+
)
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
if parsed.signature_segment.strip():
|
|
45
|
+
findings.append(
|
|
46
|
+
Finding(
|
|
47
|
+
check=CHECK_NAME,
|
|
48
|
+
title="alg: none token carries a non-empty signature segment",
|
|
49
|
+
description=(
|
|
50
|
+
"alg is 'none' but the signature segment is non-empty. This is "
|
|
51
|
+
"unusual — a spec-compliant 'none' token has an empty third "
|
|
52
|
+
"segment. The signature bytes present here are ignored by any "
|
|
53
|
+
"verifier that honors alg: none, so they provide no protection."
|
|
54
|
+
),
|
|
55
|
+
severity="low",
|
|
56
|
+
evidence={"alg": alg, "signature_segment_length": len(parsed.signature_segment)},
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return findings
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Shared types for check modules.
|
|
2
|
+
|
|
3
|
+
Every check module in this package exposes a `check(parsed, **kwargs) ->
|
|
4
|
+
list[Finding]` function and returns findings shaped like `Finding.to_dict()`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
Severity = Literal["info", "low", "medium", "high", "critical"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Finding:
|
|
17
|
+
check: str
|
|
18
|
+
title: str
|
|
19
|
+
description: str
|
|
20
|
+
severity: Severity
|
|
21
|
+
evidence: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, Any]:
|
|
24
|
+
return {
|
|
25
|
+
"check": self.check,
|
|
26
|
+
"title": self.title,
|
|
27
|
+
"description": self.description,
|
|
28
|
+
"severity": self.severity,
|
|
29
|
+
"evidence": self.evidence,
|
|
30
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Standard claim hygiene checks (RFC 7519 section 4.1).
|
|
2
|
+
|
|
3
|
+
Covers: missing exp, already-expired exp (informational), missing iat, nbf
|
|
4
|
+
that looks wrong relative to iat, and unreasonably long-lived tokens.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from jwtlint.checks.base import Finding
|
|
12
|
+
from jwtlint.parser import ParsedToken
|
|
13
|
+
|
|
14
|
+
CHECK_NAME = "claims"
|
|
15
|
+
|
|
16
|
+
# A year, as a round default for "this token lives suspiciously long."
|
|
17
|
+
DEFAULT_MAX_LIFETIME_SECONDS = 365 * 24 * 3600
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _numeric_claim(payload: dict, name: str) -> float | None:
|
|
21
|
+
value = payload.get(name)
|
|
22
|
+
if isinstance(value, bool): # bool is a subclass of int, exclude explicitly
|
|
23
|
+
return None
|
|
24
|
+
if isinstance(value, (int, float)):
|
|
25
|
+
return float(value)
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check(parsed: ParsedToken, max_lifetime_seconds: int | None = None) -> list[Finding]:
|
|
30
|
+
threshold = max_lifetime_seconds if max_lifetime_seconds is not None else DEFAULT_MAX_LIFETIME_SECONDS
|
|
31
|
+
payload = parsed.payload
|
|
32
|
+
findings: list[Finding] = []
|
|
33
|
+
now = time.time()
|
|
34
|
+
|
|
35
|
+
exp = _numeric_claim(payload, "exp")
|
|
36
|
+
iat = _numeric_claim(payload, "iat")
|
|
37
|
+
nbf = _numeric_claim(payload, "nbf")
|
|
38
|
+
|
|
39
|
+
if "exp" not in payload:
|
|
40
|
+
findings.append(
|
|
41
|
+
Finding(
|
|
42
|
+
check=CHECK_NAME,
|
|
43
|
+
title="No exp (expiration) claim",
|
|
44
|
+
description=(
|
|
45
|
+
"The token has no exp claim, so it never expires by "
|
|
46
|
+
"definition. A stolen or leaked token remains valid "
|
|
47
|
+
"indefinitely unless revoked out-of-band. Every token "
|
|
48
|
+
"issued to a client should carry a bounded expiration."
|
|
49
|
+
),
|
|
50
|
+
severity="high",
|
|
51
|
+
evidence={},
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
elif exp is None:
|
|
55
|
+
findings.append(
|
|
56
|
+
Finding(
|
|
57
|
+
check=CHECK_NAME,
|
|
58
|
+
title="exp claim is not a valid NumericDate",
|
|
59
|
+
description=(
|
|
60
|
+
"exp is present but is not a number (RFC 7519 requires exp to "
|
|
61
|
+
"be a NumericDate — seconds since the Unix epoch). A verifier "
|
|
62
|
+
"that fails to validate this type may treat the token as "
|
|
63
|
+
"never expiring."
|
|
64
|
+
),
|
|
65
|
+
severity="medium",
|
|
66
|
+
evidence={"exp": payload.get("exp")},
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
elif exp < now:
|
|
70
|
+
findings.append(
|
|
71
|
+
Finding(
|
|
72
|
+
check=CHECK_NAME,
|
|
73
|
+
title="Token is expired",
|
|
74
|
+
description=(
|
|
75
|
+
f"exp ({exp:.0f}) is in the past relative to the current time "
|
|
76
|
+
f"({now:.0f}). This isn't a vulnerability by itself — an "
|
|
77
|
+
"expired token should be rejected by any correct verifier — "
|
|
78
|
+
"it's just noted for context."
|
|
79
|
+
),
|
|
80
|
+
severity="info",
|
|
81
|
+
evidence={"exp": exp, "now": now, "expired_seconds_ago": now - exp},
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if "iat" not in payload:
|
|
86
|
+
findings.append(
|
|
87
|
+
Finding(
|
|
88
|
+
check=CHECK_NAME,
|
|
89
|
+
title="No iat (issued-at) claim",
|
|
90
|
+
description=(
|
|
91
|
+
"The token has no iat claim. iat isn't required by the spec, "
|
|
92
|
+
"but without it there's no record of when the token was "
|
|
93
|
+
"issued, which makes it harder to reason about token age, "
|
|
94
|
+
"detect replay of very old tokens, or audit issuance."
|
|
95
|
+
),
|
|
96
|
+
severity="low",
|
|
97
|
+
evidence={},
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
elif iat is None:
|
|
101
|
+
findings.append(
|
|
102
|
+
Finding(
|
|
103
|
+
check=CHECK_NAME,
|
|
104
|
+
title="iat claim is not a valid NumericDate",
|
|
105
|
+
description="iat is present but is not a number (RFC 7519 NumericDate).",
|
|
106
|
+
severity="low",
|
|
107
|
+
evidence={"iat": payload.get("iat")},
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if nbf is not None and iat is not None and nbf < iat:
|
|
112
|
+
findings.append(
|
|
113
|
+
Finding(
|
|
114
|
+
check=CHECK_NAME,
|
|
115
|
+
title="nbf predates iat",
|
|
116
|
+
description=(
|
|
117
|
+
f"nbf ({nbf:.0f}) is earlier than iat ({iat:.0f}) — the token "
|
|
118
|
+
"claims to be valid before it was issued. This is logically "
|
|
119
|
+
"inconsistent and may indicate a clock issue on the issuer or "
|
|
120
|
+
"a hand-crafted/forged token."
|
|
121
|
+
),
|
|
122
|
+
severity="low",
|
|
123
|
+
evidence={"nbf": nbf, "iat": iat},
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if exp is not None and iat is not None:
|
|
128
|
+
lifetime = exp - iat
|
|
129
|
+
if lifetime > threshold:
|
|
130
|
+
findings.append(
|
|
131
|
+
Finding(
|
|
132
|
+
check=CHECK_NAME,
|
|
133
|
+
title="Unusually long token lifetime",
|
|
134
|
+
description=(
|
|
135
|
+
f"exp - iat is {lifetime:.0f} seconds (~{lifetime / 86400:.0f} days), "
|
|
136
|
+
f"above the {threshold} second (~{threshold / 86400:.0f} day) threshold. "
|
|
137
|
+
"Long-lived tokens widen the window an attacker can use a "
|
|
138
|
+
"stolen token, and make revocation more important since "
|
|
139
|
+
"there's no natural expiry to rely on."
|
|
140
|
+
),
|
|
141
|
+
severity="medium",
|
|
142
|
+
evidence={"exp": exp, "iat": iat, "lifetime_seconds": lifetime, "threshold_seconds": threshold},
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return findings
|