oauthlint-rules 0.1.0

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +3 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/loader.d.ts +35 -0
  8. package/dist/loader.d.ts.map +1 -0
  9. package/dist/loader.js +72 -0
  10. package/dist/loader.js.map +1 -0
  11. package/dist/manifest.d.ts +10 -0
  12. package/dist/manifest.d.ts.map +1 -0
  13. package/dist/manifest.js +10 -0
  14. package/dist/manifest.js.map +1 -0
  15. package/dist/schema.d.ts +425 -0
  16. package/dist/schema.d.ts.map +1 -0
  17. package/dist/schema.js +71 -0
  18. package/dist/schema.js.map +1 -0
  19. package/package.json +61 -0
  20. package/rules/cookie/long-lived.yml +42 -0
  21. package/rules/cookie/no-httponly.yml +54 -0
  22. package/rules/cookie/no-samesite.yml +56 -0
  23. package/rules/cookie/no-secure.yml +58 -0
  24. package/rules/cors/wildcard-with-credentials.yml +56 -0
  25. package/rules/flow/insecure-random.yml +56 -0
  26. package/rules/flow/no-rate-limit.yml +39 -0
  27. package/rules/flow/password-min-length.yml +44 -0
  28. package/rules/flow/password-plaintext.yml +82 -0
  29. package/rules/flow/timing-unsafe-compare.yml +103 -0
  30. package/rules/jwt/alg-none.yml +46 -0
  31. package/rules/jwt/algorithm-confusion.yml +67 -0
  32. package/rules/jwt/in-url.yml +33 -0
  33. package/rules/jwt/localstorage.yml +39 -0
  34. package/rules/jwt/no-audience.yml +49 -0
  35. package/rules/jwt/no-expiration.yml +45 -0
  36. package/rules/jwt/no-issuer.yml +40 -0
  37. package/rules/jwt/weak-secret.yml +56 -0
  38. package/rules/oauth/broad-scope.yml +39 -0
  39. package/rules/oauth/hardcoded-secret.yml +41 -0
  40. package/rules/oauth/implicit-flow.yml +36 -0
  41. package/rules/oauth/long-token-lifetime.yml +57 -0
  42. package/rules/oauth/no-pkce.yml +50 -0
  43. package/rules/oauth/no-state-validation.yml +42 -0
  44. package/rules/oauth/no-state.yml +41 -0
  45. package/rules/oauth/open-redirect-callback.yml +47 -0
  46. package/rules/oauth/wildcard-redirect.yml +45 -0
  47. package/rules/secret/provider-key.yml +44 -0
  48. package/rules/session/id-in-url.yml +30 -0
  49. package/rules/session/no-regeneration.yml +53 -0
@@ -0,0 +1,39 @@
1
+ rules:
2
+ - id: auth.jwt.localstorage
3
+ languages:
4
+ - javascript
5
+ - typescript
6
+ severity: WARNING
7
+ message: |
8
+ A JWT (or other auth token) is being written to `localStorage`. Any
9
+ XSS that lands on the page can exfiltrate the token via
10
+ `localStorage.getItem(...)` — and unlike `HttpOnly` cookies, there
11
+ is no browser-side mitigation.
12
+
13
+ Store auth tokens in an `HttpOnly; Secure; SameSite=Strict` cookie
14
+ set by the server, or in memory only. `sessionStorage` is no safer
15
+ against XSS — it's the same attacker capability.
16
+
17
+ OWASP ASVS V3.4: tokens must not be stored where untrusted scripts
18
+ can read them.
19
+ # The key must contain a strong token word (`token` catches accessToken,
20
+ # refresh_token, authToken, idToken, sessionToken, …). We deliberately do
21
+ # NOT match the bare words `auth`/`access`/`refresh`/`session` because they
22
+ # appear as substrings of innocuous keys (author_filter, auto_refresh,
23
+ # access_count, sidebar_session) and produced false positives.
24
+ pattern-either:
25
+ - patterns:
26
+ - pattern-regex: |-
27
+ (?:window\.|globalThis\.|self\.)?(?:localStorage|sessionStorage)\.setItem\(\s*['"][^'"]*(?i:token|jwt|bearer|credential|api[_-]?key)[^'"]*['"]
28
+ - patterns:
29
+ - pattern-regex: |-
30
+ (?:window\.|globalThis\.|self\.)?(?:localStorage|sessionStorage)\[\s*['"][^'"]*(?i:token|jwt|bearer|credential|api[_-]?key)[^'"]*['"]\s*\]\s*=
31
+ metadata:
32
+ oauthlint-rule-id: AUTH-JWT-005
33
+ oauthlint-doc-url: https://oauthlint.dev/rules/jwt-localstorage
34
+ category: security
35
+ cwe: CWE-922
36
+ owasp: API8:2023
37
+ llm-prevalence: HIGH
38
+ references:
39
+ - https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html
@@ -0,0 +1,49 @@
1
+ rules:
2
+ - id: auth.jwt.no-audience
3
+ languages:
4
+ - javascript
5
+ - typescript
6
+ severity: WARNING
7
+ message: |
8
+ JWT is being verified without checking the `aud` (audience) claim.
9
+ A token issued for one of your services (e.g. an internal worker)
10
+ can then be replayed against another service that trusts the same
11
+ key, leading to confused-deputy attacks.
12
+
13
+ Pass `{ audience: 'your-api' }` to `jwt.verify` (or validate the
14
+ `aud` claim manually) on every verification path.
15
+
16
+ RFC 7519 §4.1.3 defines the `aud` claim explicitly for this use case.
17
+ # Scoped to the `jsonwebtoken` library by matching the common alias
18
+ # `jwt` (the literal identifier 99% of users pick). Generic `X.verify(...)`
19
+ # calls on unrelated libs (jose internals, custom signers) no longer
20
+ # fire — this was the dominant FP class in real-world validation.
21
+ pattern-either:
22
+ - patterns:
23
+ - pattern: 'jwt.verify($TOKEN, $SECRET, $OPTS)'
24
+ - metavariable-pattern:
25
+ metavariable: $OPTS
26
+ patterns:
27
+ - pattern-not: '{..., audience: ..., ...}'
28
+ - pattern: '{...}'
29
+ # Callback form: jwt.verify(token, secret, options, cb) without audience.
30
+ - patterns:
31
+ - pattern: 'jwt.verify($TOKEN, $SECRET, $OPTS, $CB)'
32
+ - metavariable-pattern:
33
+ metavariable: $OPTS
34
+ patterns:
35
+ - pattern-not: '{..., audience: ..., ...}'
36
+ - pattern: '{...}'
37
+ - patterns:
38
+ - pattern: 'jwt.verify($TOKEN, $SECRET)'
39
+ metadata:
40
+ oauthlint-rule-id: AUTH-JWT-004
41
+ oauthlint-doc-url: https://oauthlint.dev/rules/jwt-no-audience
42
+ category: security
43
+ cwe: CWE-345
44
+ owasp: API2:2023
45
+ llm-prevalence: MEDIUM
46
+ technology:
47
+ - jsonwebtoken
48
+ references:
49
+ - https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
@@ -0,0 +1,45 @@
1
+ rules:
2
+ - id: auth.jwt.no-expiration
3
+ languages:
4
+ - javascript
5
+ - typescript
6
+ severity: WARNING
7
+ message: |
8
+ JWT is signed without any `expiresIn` / `exp` claim, OR a token is
9
+ verified without an `maxAge` check. A stolen token therefore remains
10
+ valid forever.
11
+
12
+ Always set a reasonable expiration on access tokens (5-60 minutes is
13
+ typical) and verify it with `{ maxAge: '15m' }` or by validating the
14
+ `exp` claim explicitly.
15
+ # Scoped to the `jsonwebtoken` library by matching the common alias
16
+ # `jwt`. Generic `X.sign(...)` calls on unrelated libs (jose
17
+ # internals, custom signers, ECDSA primitives) no longer fire —
18
+ # this was the dominant FP class in real-world validation.
19
+ pattern-either:
20
+ - patterns:
21
+ - pattern: 'jwt.sign($PAYLOAD, $SECRET)'
22
+ - pattern-not: 'jwt.sign({..., exp: ..., ...}, $SECRET)'
23
+ - pattern-not: 'jwt.sign({..., expiresIn: ..., ...}, $SECRET)'
24
+ - patterns:
25
+ - pattern: 'jwt.sign($PAYLOAD, $SECRET, $OPTS)'
26
+ # exp can live in the payload too, not only the options object.
27
+ - pattern-not: 'jwt.sign({..., exp: ..., ...}, $SECRET, $OPTS)'
28
+ - pattern-not: 'jwt.sign({..., expiresIn: ..., ...}, $SECRET, $OPTS)'
29
+ - metavariable-pattern:
30
+ metavariable: $OPTS
31
+ patterns:
32
+ - pattern-not: '{..., expiresIn: ..., ...}'
33
+ - pattern-not: '{..., exp: ..., ...}'
34
+ - pattern: '{...}'
35
+ metadata:
36
+ oauthlint-rule-id: AUTH-JWT-003
37
+ oauthlint-doc-url: https://oauthlint.dev/rules/jwt-no-expiration
38
+ category: security
39
+ cwe: CWE-613
40
+ owasp: API2:2023
41
+ llm-prevalence: HIGH
42
+ technology:
43
+ - jsonwebtoken
44
+ references:
45
+ - https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4
@@ -0,0 +1,40 @@
1
+ rules:
2
+ - id: auth.jwt.no-issuer
3
+ languages:
4
+ - javascript
5
+ - typescript
6
+ severity: INFO
7
+ message: |
8
+ JWT is being verified without checking the `iss` (issuer) claim. If
9
+ your verification key is shared across multiple authorization
10
+ servers (or even tenants on a single IdP), this lets a token signed
11
+ by one issuer be accepted by code that was meant to trust another.
12
+
13
+ Pass `{ issuer: 'https://your-idp.example.com' }` to `jwt.verify` so
14
+ that the trust chain is explicit. RFC 7519 §4.1.1 defines the
15
+ `iss` claim for exactly this purpose.
16
+ # Scoped to `jsonwebtoken` (alias `jwt`) for the same reason as
17
+ # auth.jwt.no-audience: generic $X.verify() patterns produced too
18
+ # many FPs on jose/custom signer code.
19
+ pattern-either:
20
+ - patterns:
21
+ - pattern: 'jwt.verify($TOKEN, $SECRET, $OPTS)'
22
+ - metavariable-pattern:
23
+ metavariable: $OPTS
24
+ patterns:
25
+ - pattern-not: '{..., issuer: ..., ...}'
26
+ - pattern: '{...}'
27
+ # 2-arg verify has no options at all → no issuer check (parity with
28
+ # auth.jwt.no-audience).
29
+ - pattern: 'jwt.verify($TOKEN, $SECRET)'
30
+ metadata:
31
+ oauthlint-rule-id: AUTH-JWT-006
32
+ oauthlint-doc-url: https://oauthlint.dev/rules/jwt-no-issuer
33
+ category: security
34
+ cwe: CWE-345
35
+ owasp: API2:2023
36
+ llm-prevalence: LOW
37
+ technology:
38
+ - jsonwebtoken
39
+ references:
40
+ - https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1
@@ -0,0 +1,56 @@
1
+ rules:
2
+ - id: auth.jwt.weak-secret
3
+ languages:
4
+ - javascript
5
+ - typescript
6
+ severity: ERROR
7
+ message: |
8
+ JWT signing or verification uses a hard-coded secret. Anyone who reads
9
+ the source (including via leaked GitHub commits) can forge valid tokens.
10
+
11
+ Move the secret to an environment variable or secret manager, and ensure
12
+ the value is at least 256 bits (32 ASCII characters) when using HMAC-based
13
+ algorithms (HS256/HS384/HS512).
14
+
15
+ Real-world incident: 24,008 unique secrets were found in MCP config files
16
+ and 3.2% of Claude Code commits leaked secrets in GitGuardian's 2026 report.
17
+ # Scoped to the `jsonwebtoken` alias `jwt` to avoid matching jose's
18
+ # low-level `sig.sign(key, options)` and similar generic patterns
19
+ # that were the FP class in real-world validation.
20
+ pattern-either:
21
+ - patterns:
22
+ - pattern-either:
23
+ - pattern: 'jwt.sign($PAYLOAD, $SECRET)'
24
+ - pattern: 'jwt.sign($PAYLOAD, $SECRET, ...)'
25
+ - pattern: 'jwt.verify($TOKEN, $SECRET)'
26
+ - pattern: 'jwt.verify($TOKEN, $SECRET, ...)'
27
+ - metavariable-regex:
28
+ metavariable: $SECRET
29
+ regex: ^(["'])(?:[a-zA-Z0-9!._-]{1,20}|secret|password|changeme|jwt[-_]?secret|my[-_]?secret)\1$
30
+ # Destructured import: `import { sign, verify } from 'jsonwebtoken'`.
31
+ # Scoped to the import so bare sign()/verify() from other libraries are
32
+ # not matched.
33
+ - patterns:
34
+ - pattern-inside: |
35
+ import { ... } from 'jsonwebtoken'
36
+ ...
37
+ - pattern-either:
38
+ - pattern: 'sign($PAYLOAD, $SECRET)'
39
+ - pattern: 'sign($PAYLOAD, $SECRET, ...)'
40
+ - pattern: 'verify($TOKEN, $SECRET)'
41
+ - pattern: 'verify($TOKEN, $SECRET, ...)'
42
+ - metavariable-regex:
43
+ metavariable: $SECRET
44
+ regex: ^(["'])(?:[a-zA-Z0-9!._-]{1,20}|secret|password|changeme|jwt[-_]?secret|my[-_]?secret)\1$
45
+ metadata:
46
+ oauthlint-rule-id: AUTH-JWT-002
47
+ oauthlint-doc-url: https://oauthlint.dev/rules/jwt-weak-secret
48
+ category: security
49
+ cwe: CWE-798
50
+ owasp: API2:2023
51
+ llm-prevalence: HIGH
52
+ technology:
53
+ - jsonwebtoken
54
+ references:
55
+ - https://datatracker.ietf.org/doc/html/rfc7518#section-3.2
56
+ - https://cwe.mitre.org/data/definitions/798.html
@@ -0,0 +1,39 @@
1
+ rules:
2
+ - id: auth.oauth.broad-scope
3
+ languages:
4
+ - javascript
5
+ - typescript
6
+ severity: INFO
7
+ message: |
8
+ OAuth scope request includes an over-broad scope such as `admin`,
9
+ `full_access`, `*`, or `repo` (entire GitHub access). LLMs default
10
+ to the widest scope that "works", but every extra scope expands
11
+ the blast radius if the access token is leaked or replayed.
12
+
13
+ Request the narrowest scope that satisfies your feature. Examples:
14
+ use `repo:status` instead of `repo`, `gmail.send` instead of
15
+ `https://mail.google.com/`, and scope down to `read:user` when you
16
+ only need a profile.
17
+ # An over-broad scope must be a STANDALONE token (bounded by the quote, a
18
+ # space, or a comma — OAuth scopes are space-delimited). Structured
19
+ # sub-scopes such as `admin:read`, `repo:status`, `public_repo`,
20
+ # `channels:admin`, `calendar.all` are narrower and intentionally NOT
21
+ # flagged (they previously matched via `\b` and produced false positives).
22
+ pattern-either:
23
+ - pattern-regex: |-
24
+ scope\s*[:=]\s*(['"])(?:[^'"]*[ ,])?(?:admin|full_access|all|root|superuser|repo)(?:[ ,][^'"]*)?\1
25
+ - pattern-regex: |-
26
+ scope\s*[:=]\s*['"][^'"]*\*[^'"]*['"]
27
+ - pattern-regex: |-
28
+ scope\s*[:=]\s*['"][^'"]*https://mail\.google\.com/[^'"]*['"]
29
+ - pattern-regex: |-
30
+ scopes?\s*[:=]\s*\[[^\]]*(['"])(?:[^'"]*[ ,])?(?:admin|full_access|all|root|superuser|repo)(?:[ ,][^'"]*)?\1[^\]]*\]
31
+ metadata:
32
+ oauthlint-rule-id: AUTH-OAUTH-006
33
+ oauthlint-doc-url: https://oauthlint.dev/rules/oauth-broad-scope
34
+ category: security
35
+ cwe: CWE-272
36
+ owasp: API1:2023
37
+ llm-prevalence: HIGH
38
+ references:
39
+ - https://datatracker.ietf.org/doc/html/rfc6749#section-3.3
@@ -0,0 +1,41 @@
1
+ rules:
2
+ - id: auth.oauth.hardcoded-secret
3
+ languages:
4
+ - javascript
5
+ - typescript
6
+ severity: ERROR
7
+ message: |
8
+ An OAuth `client_secret` (or similarly sensitive credential) is being
9
+ assigned a hard-coded string literal. The moment this lands in git, it
10
+ is one search away from compromise.
11
+
12
+ Replace the literal with `process.env.OAUTH_CLIENT_SECRET` (or your
13
+ secret manager equivalent) and add the variable to `.env.example` with
14
+ a placeholder so contributors know it is required.
15
+
16
+ GitGuardian 2026 found 28.6M public secrets on GitHub, with Claude Code
17
+ commits leaking at 2x baseline. This is the most common AI-coding leak.
18
+ # Key matching is case-insensitive (client_secret / clientSecret /
19
+ # CLIENT_SECRET). The value must be a quoted ≥8-char literal, so `process.env`
20
+ # references never match. Env-var template strings, <placeholders>, and
21
+ # obvious doc/test placeholders are excluded to avoid false positives.
22
+ pattern-either:
23
+ - patterns:
24
+ - pattern-regex: |-
25
+ (?i)client[_-]?secret\s*[:=]\s*['"][^'"\s]{8,}['"]
26
+ - pattern-not-regex: |-
27
+ (?i)client[_-]?secret\s*[:=]\s*['"]\$\{?[A-Za-z_]+\}?['"]
28
+ - pattern-not-regex: |-
29
+ (?i)client[_-]?secret\s*[:=]\s*['"]<[^'"]*>['"]
30
+ - pattern-not-regex: |-
31
+ (?i)client[_-]?secret\s*[:=]\s*['"](?:your[-_]|my[-_]|example|placeholder|xxx+|todo|fixme|test|dummy|fake|sample|changeme|redacted)
32
+ metadata:
33
+ oauthlint-rule-id: AUTH-OAUTH-003
34
+ oauthlint-doc-url: https://oauthlint.dev/rules/oauth-hardcoded-secret
35
+ category: security
36
+ cwe: CWE-798
37
+ owasp: API8:2023
38
+ llm-prevalence: HIGH
39
+ references:
40
+ - https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
41
+ - https://blog.gitguardian.com/the-state-of-secrets-sprawl-2026/
@@ -0,0 +1,36 @@
1
+ rules:
2
+ - id: auth.oauth.implicit-flow
3
+ languages:
4
+ - javascript
5
+ - typescript
6
+ severity: ERROR
7
+ message: |
8
+ OAuth implicit flow (`response_type=token` or `response_type=id_token
9
+ token`) is deprecated by OAuth 2.0 Security BCP (RFC 9700) and the
10
+ OAuth 2.1 working draft. The access token leaks into the URL
11
+ fragment, browser history, and referrer headers, and there is no
12
+ refresh-token mechanism.
13
+
14
+ Migrate to authorization code + PKCE (`response_type=code` with a
15
+ `code_challenge`). All modern OAuth providers (Google, Microsoft,
16
+ Auth0, Okta, Keycloak, WSO2) support this for SPAs and native apps.
17
+ pattern-either:
18
+ - pattern-regex: |-
19
+ response_type\s*[:=]\s*['"]token['"]
20
+ - pattern-regex: |-
21
+ response_type\s*[:=]\s*['"]id_token\s+token['"]
22
+ - pattern-regex: |-
23
+ response_type\s*[:=]\s*['"]token\s+id_token['"]
24
+ - pattern-regex: |-
25
+ [?&]response_type=token(?:&|['"]|#|$)
26
+ - pattern-regex: |-
27
+ [?&]response_type=(?:id_token(?:%20|\+)token|token(?:%20|\+)id_token)(?:&|['"]|#|$)
28
+ metadata:
29
+ oauthlint-rule-id: AUTH-OAUTH-005
30
+ oauthlint-doc-url: https://oauthlint.dev/rules/oauth-implicit-flow
31
+ category: security
32
+ cwe: CWE-1004
33
+ owasp: API1:2023
34
+ llm-prevalence: MEDIUM
35
+ references:
36
+ - https://datatracker.ietf.org/doc/html/rfc9700
@@ -0,0 +1,57 @@
1
+ rules:
2
+ - id: auth.oauth.long-token-lifetime
3
+ languages:
4
+ - javascript
5
+ - typescript
6
+ severity: WARNING
7
+ message: |
8
+ An OAuth `expires_in` (or comparable token-lifetime field) is being
9
+ set to a literal value greater than 86_400 seconds — i.e. longer
10
+ than 24 hours. Long-lived access tokens make every token theft
11
+ catastrophic because they remain valid for days or weeks; the
12
+ industry standard is 15-60 minutes for access tokens, paired with
13
+ a refresh-token rotation flow for longer sessions.
14
+
15
+ Don't issue access tokens longer than a day. Use refresh tokens
16
+ with proper rotation (RFC 6749 §6, RFC 9700 §4.14) for "stay
17
+ logged in" semantics.
18
+ pattern-either:
19
+ # Numeric literal as an object property.
20
+ - patterns:
21
+ - pattern-either:
22
+ - pattern: '{ ..., expires_in: $TTL, ... }'
23
+ - pattern: '{ ..., expiresIn: $TTL, ... }'
24
+ - pattern: '{ ..., access_token_ttl: $TTL, ... }'
25
+ - pattern: '{ ..., accessTokenTtl: $TTL, ... }'
26
+ - pattern: '{ ..., tokenLifetime: $TTL, ... }'
27
+ - metavariable-comparison:
28
+ metavariable: $TTL
29
+ comparison: $TTL > 86400
30
+ # Numeric literal via member assignment (config.expires_in = N).
31
+ - patterns:
32
+ - pattern-either:
33
+ - pattern: '$X.expires_in = $TTL'
34
+ - pattern: '$X.expiresIn = $TTL'
35
+ - pattern: '$X.access_token_ttl = $TTL'
36
+ - pattern: '$X.accessTokenTtl = $TTL'
37
+ - pattern: '$X.tokenLifetime = $TTL'
38
+ - metavariable-comparison:
39
+ metavariable: $TTL
40
+ comparison: $TTL > 86400
41
+ # jsonwebtoken / `ms`-style string durations longer than a day: >=2 days,
42
+ # or any weeks / months / years. ('1d', '15m', '12h' are <= 24h and are
43
+ # intentionally not matched.)
44
+ - pattern-regex: |-
45
+ (?:expiresIn|expires_in)\s*[:=]\s*['"](?:[2-9]|[1-9][0-9]+)\s*(?:d|days?)['"]
46
+ - pattern-regex: |-
47
+ (?:expiresIn|expires_in)\s*[:=]\s*['"][1-9][0-9]*\s*(?:w|y|weeks?|years?|months?)['"]
48
+ metadata:
49
+ oauthlint-rule-id: AUTH-OAUTH-009
50
+ oauthlint-doc-url: https://oauthlint.dev/rules/oauth-long-token-lifetime
51
+ category: security
52
+ cwe: CWE-613
53
+ owasp: API2:2023
54
+ llm-prevalence: MEDIUM
55
+ references:
56
+ - https://datatracker.ietf.org/doc/html/rfc9700#section-4.14
57
+ - https://datatracker.ietf.org/doc/html/rfc6749#section-6
@@ -0,0 +1,50 @@
1
+ rules:
2
+ - id: auth.oauth.no-pkce
3
+ languages:
4
+ - javascript
5
+ - typescript
6
+ severity: WARNING
7
+ message: |
8
+ OAuth 2.0 authorization request looks like a public client (SPA,
9
+ mobile, or native app) but does not include a `code_challenge`
10
+ parameter. Without PKCE, the authorization code can be intercepted
11
+ and exchanged by an attacker.
12
+
13
+ RFC 8252 §6 mandates PKCE for native/SPA clients. RFC 9700 (OAuth 2.0
14
+ Security BCP) recommends PKCE for ALL clients, including confidential
15
+ ones, as defence in depth.
16
+
17
+ Generate a `code_verifier` (43-128 char), derive `code_challenge =
18
+ BASE64URL-NoPad(SHA256(code_verifier))`, send it with
19
+ `code_challenge_method=S256` on the authorize call, and POST the
20
+ `code_verifier` on the token call.
21
+ pattern-either:
22
+ # Inline authorize URL — any provider's authorize endpoint carrying an
23
+ # authorization-code request (client_id + response_type=code) with no
24
+ # code_challenge. The path is not hardcoded (Google uses /o/oauth2/v2/auth,
25
+ # Auth0/Okta /authorize, GitHub /oauth/authorize, …).
26
+ - patterns:
27
+ - pattern-regex: |-
28
+ ['"]https?://[^'"\s]+\?[^'"]*response_type=code[^'"]*['"]
29
+ - pattern-regex: |-
30
+ client_id=
31
+ - pattern-not-regex: |-
32
+ code_challenge
33
+ # Programmatic URLSearchParams build (AST object match — robust to key
34
+ # order and object length, unlike a char-window regex).
35
+ - patterns:
36
+ - pattern: 'new URLSearchParams({..., client_id: $C, ...})'
37
+ - pattern-either:
38
+ - pattern: "new URLSearchParams({..., response_type: 'code', ...})"
39
+ - pattern: 'new URLSearchParams({..., response_type: "code", ...})'
40
+ - pattern-not: 'new URLSearchParams({..., code_challenge: $X, ...})'
41
+ metadata:
42
+ oauthlint-rule-id: AUTH-OAUTH-004
43
+ oauthlint-doc-url: https://oauthlint.dev/rules/oauth-no-pkce
44
+ category: security
45
+ cwe: CWE-345
46
+ owasp: API1:2023
47
+ llm-prevalence: HIGH
48
+ references:
49
+ - https://datatracker.ietf.org/doc/html/rfc7636
50
+ - https://datatracker.ietf.org/doc/html/rfc8252#section-6
@@ -0,0 +1,42 @@
1
+ rules:
2
+ - id: auth.oauth.no-state-validation
3
+ languages:
4
+ - javascript
5
+ - typescript
6
+ severity: WARNING
7
+ message: |
8
+ OAuth callback handler reads `state` from the request but never
9
+ compares it to a stored value. Sending `state` on the authorize
10
+ call is half of the CSRF mitigation; verifying it on the callback
11
+ is the other half.
12
+
13
+ Compare the received `state` to the value you stored before
14
+ redirecting (session, signed cookie, or Redis). Reject the callback
15
+ if it's missing or doesn't match.
16
+ # Suppress when the received `state` is referenced inside ANY `if`
17
+ # condition — that's the validation. The deep-expression operator
18
+ # `<... state ...>` makes this order-agnostic and helper-aware, so
19
+ # `if (req.query.state !== stored)`, `if (stored === req.query.state)`, and
20
+ # `if (!verifyState(req.query.state))` are all treated as safe (the previous
21
+ # exact-shape patterns only matched one operand order → false positives).
22
+ pattern-either:
23
+ - patterns:
24
+ - pattern-either:
25
+ - pattern: '$REQ.query.state'
26
+ - pattern: '$REQ.body.state'
27
+ - pattern: '$URL.searchParams.get("state")'
28
+ - pattern-not-inside: |
29
+ if (<... $REQ.query.state ...>) { ... }
30
+ - pattern-not-inside: |
31
+ if (<... $REQ.body.state ...>) { ... }
32
+ - pattern-not-inside: |
33
+ if (<... $URL.searchParams.get("state") ...>) { ... }
34
+ metadata:
35
+ oauthlint-rule-id: AUTH-OAUTH-007
36
+ oauthlint-doc-url: https://oauthlint.dev/rules/oauth-no-state-validation
37
+ category: security
38
+ cwe: CWE-352
39
+ owasp: API1:2023
40
+ llm-prevalence: HIGH
41
+ references:
42
+ - https://datatracker.ietf.org/doc/html/rfc6749#section-10.12
@@ -0,0 +1,41 @@
1
+ rules:
2
+ - id: auth.oauth.no-state
3
+ languages:
4
+ - javascript
5
+ - typescript
6
+ severity: ERROR
7
+ message: |
8
+ OAuth 2.0 authorization request is being built WITHOUT a `state`
9
+ parameter. This opens you to CSRF attacks during the OAuth dance —
10
+ an attacker can trick the victim into logging in as the attacker.
11
+
12
+ Always generate a cryptographically random `state`, store it in a
13
+ session/cookie, and validate it on the callback. PKCE alone is not a
14
+ substitute for `state` when handling browser sessions.
15
+ pattern-either:
16
+ # Inline authorize URL carrying both client_id and response_type but no
17
+ # state. The path is not hardcoded (Google uses /o/oauth2/v2/auth, etc.).
18
+ - patterns:
19
+ - pattern-regex: |-
20
+ ['"]https?://[^'"\s]+\?[^'"]*client_id=[^'"]*['"]
21
+ - pattern-regex: |-
22
+ response_type=
23
+ - pattern-not-regex: |-
24
+ state=
25
+ # Programmatic URLSearchParams build (AST object match — robust to key
26
+ # order and length). Fires when client_id + response_type are present but
27
+ # state is not (covers both `state: x` and the `state` shorthand).
28
+ - patterns:
29
+ - pattern: 'new URLSearchParams({..., client_id: $C, ...})'
30
+ - pattern: 'new URLSearchParams({..., response_type: $R, ...})'
31
+ - pattern-not: 'new URLSearchParams({..., state: $S, ...})'
32
+ - pattern-not: 'new URLSearchParams({..., state, ...})'
33
+ metadata:
34
+ oauthlint-rule-id: AUTH-OAUTH-001
35
+ oauthlint-doc-url: https://oauthlint.dev/rules/oauth-no-state
36
+ category: security
37
+ cwe: CWE-352
38
+ owasp: API1:2023
39
+ llm-prevalence: HIGH
40
+ references:
41
+ - https://datatracker.ietf.org/doc/html/rfc6749#section-10.12
@@ -0,0 +1,47 @@
1
+ rules:
2
+ - id: auth.oauth.open-redirect-callback
3
+ languages:
4
+ - javascript
5
+ - typescript
6
+ severity: ERROR
7
+ message: |
8
+ The OAuth callback handler redirects to a URL taken straight from
9
+ the request without validating it. An attacker can craft a phishing
10
+ link to your real callback that forwards the victim to a malicious
11
+ site under your domain's trust.
12
+
13
+ Maintain an explicit allow-list of post-login redirect destinations
14
+ (route names or full URLs you control). Never forward to an
15
+ arbitrary `req.query.redirect_to`, `req.query.next`, or
16
+ `req.query.return_url` value.
17
+ # Taint mode so indirection (const next = req.query.next; res.redirect(next))
18
+ # and defaults/casts are caught, not just the direct form. Validating the
19
+ # value against an allow-list (Set.has / Array.includes) clears the taint.
20
+ mode: taint
21
+ pattern-sources:
22
+ - pattern: '$REQ.query.$A'
23
+ - pattern: '$REQ.body.$A'
24
+ - pattern: '$REQ.params.$A'
25
+ - pattern: '$REQ.query[$K]'
26
+ - pattern: '$REQ.body[$K]'
27
+ pattern-sanitizers:
28
+ - pattern: '$SET.has(...)'
29
+ - pattern: '$ARR.includes(...)'
30
+ - pattern: '$ARR.indexOf(...)'
31
+ pattern-sinks:
32
+ - patterns:
33
+ - pattern-either:
34
+ - pattern: '$RES.redirect($SINK)'
35
+ - pattern: '$RES.redirect($CODE, $SINK)'
36
+ - focus-metavariable: $SINK
37
+ metadata:
38
+ oauthlint-rule-id: AUTH-OAUTH-008
39
+ oauthlint-doc-url: https://oauthlint.dev/rules/oauth-open-redirect-callback
40
+ category: security
41
+ cwe: CWE-601
42
+ owasp: API1:2023
43
+ llm-prevalence: HIGH
44
+ technology:
45
+ - express
46
+ references:
47
+ - https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html
@@ -0,0 +1,45 @@
1
+ rules:
2
+ - id: auth.oauth.wildcard-redirect
3
+ languages:
4
+ - javascript
5
+ - typescript
6
+ severity: ERROR
7
+ message: |
8
+ OAuth `redirect_uri` allow-list contains a wildcard, an `http://` URL,
9
+ or `localhost`. Wildcards (and HTTP) let an attacker register their own
10
+ callback URL and harvest authorization codes; `localhost` whitelisting
11
+ is acceptable for dev tooling but disastrous in production.
12
+
13
+ Pin redirect URIs to exact, HTTPS URLs of subdomains you control. RFC 6749
14
+ §10.6 explicitly requires "exact match" or restricted matching.
15
+ pattern-either:
16
+ # Array allow-list containing a wildcard.
17
+ - patterns:
18
+ - pattern-regex: |-
19
+ redirect_uris?\s*[:=]\s*\[[^\]]*['"][^'"]*\*[^'"]*['"]
20
+ # Array allow-list containing an http:// URL (loopback dev URLs allowed).
21
+ - patterns:
22
+ - pattern-regex: |-
23
+ redirect_uris?\s*[:=]\s*\[[^\]]*['"]http://[^'"]*['"]
24
+ - pattern-not-regex: |-
25
+ http://(?:localhost|127\.0\.0\.1|\[::1\])
26
+ # Scalar redirect_uri with a wildcard.
27
+ - patterns:
28
+ - pattern-regex: |-
29
+ redirect_uri\s*[:=]\s*['"][^'"]*\*[^'"]*['"]
30
+ # Scalar redirect_uri with an http:// URL (loopback dev URLs allowed).
31
+ - patterns:
32
+ - pattern-regex: |-
33
+ redirect_uri\s*[:=]\s*['"]http://[^'"]*['"]
34
+ - pattern-not-regex: |-
35
+ http://(?:localhost|127\.0\.0\.1|\[::1\])
36
+ metadata:
37
+ oauthlint-rule-id: AUTH-OAUTH-002
38
+ oauthlint-doc-url: https://oauthlint.dev/rules/oauth-wildcard-redirect
39
+ category: security
40
+ cwe: CWE-601
41
+ owasp: API1:2023
42
+ llm-prevalence: MEDIUM
43
+ references:
44
+ - https://datatracker.ietf.org/doc/html/rfc6749#section-10.6
45
+ - https://datatracker.ietf.org/doc/html/rfc8252#section-7.3