eslint-plugin-jwt-security 2.3.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.
- package/AGENTS.md +102 -0
- package/CHANGELOG.md +161 -0
- package/LICENSE +23 -0
- package/README.md +158 -0
- package/dist/AGENTS.md +102 -0
- package/dist/CHANGELOG.md +161 -0
- package/dist/LICENSE +23 -0
- package/dist/README.md +158 -0
- package/dist/package.json +125 -0
- package/dist/src/index.d.ts +101 -0
- package/dist/src/index.js +171 -0
- package/dist/src/oxlint.d.ts +20 -0
- package/dist/src/oxlint.js +21 -0
- package/dist/src/rules/no-algorithm-confusion/index.d.ts +24 -0
- package/dist/src/rules/no-algorithm-confusion/index.js +165 -0
- package/dist/src/rules/no-algorithm-none/index.d.ts +24 -0
- package/dist/src/rules/no-algorithm-none/index.js +222 -0
- package/dist/src/rules/no-decode-without-verify/index.d.ts +23 -0
- package/dist/src/rules/no-decode-without-verify/index.js +131 -0
- package/dist/src/rules/no-hardcoded-secret/index.d.ts +23 -0
- package/dist/src/rules/no-hardcoded-secret/index.js +189 -0
- package/dist/src/rules/no-sensitive-payload/index.d.ts +20 -0
- package/dist/src/rules/no-sensitive-payload/index.js +103 -0
- package/dist/src/rules/no-timestamp-manipulation/index.d.ts +23 -0
- package/dist/src/rules/no-timestamp-manipulation/index.js +97 -0
- package/dist/src/rules/no-weak-secret/index.d.ts +23 -0
- package/dist/src/rules/no-weak-secret/index.js +165 -0
- package/dist/src/rules/require-algorithm-whitelist/index.d.ts +24 -0
- package/dist/src/rules/require-algorithm-whitelist/index.js +113 -0
- package/dist/src/rules/require-audience-validation/index.d.ts +20 -0
- package/dist/src/rules/require-audience-validation/index.js +100 -0
- package/dist/src/rules/require-expiration/index.d.ts +23 -0
- package/dist/src/rules/require-expiration/index.js +136 -0
- package/dist/src/rules/require-issued-at/index.d.ts +23 -0
- package/dist/src/rules/require-issued-at/index.js +113 -0
- package/dist/src/rules/require-issuer-validation/index.d.ts +20 -0
- package/dist/src/rules/require-issuer-validation/index.js +100 -0
- package/dist/src/rules/require-max-age/index.d.ts +24 -0
- package/dist/src/rules/require-max-age/index.js +93 -0
- package/dist/src/types/index.d.ts +82 -0
- package/dist/src/types/index.js +7 -0
- package/dist/src/utils/index.d.ts +98 -0
- package/dist/src/utils/index.js +238 -0
- package/package.json +125 -0
- package/src/__compatibility__/jwt-interface.spec.ts +192 -0
- package/src/index.test.ts +47 -0
- package/src/index.ts +209 -0
- package/src/oxlint.test.ts +61 -0
- package/src/oxlint.ts +23 -0
- package/src/rules/no-algorithm-confusion/index.ts +208 -0
- package/src/rules/no-algorithm-confusion/no-algorithm-confusion.test.ts +106 -0
- package/src/rules/no-algorithm-none/index.ts +270 -0
- package/src/rules/no-algorithm-none/no-algorithm-none.test.ts +146 -0
- package/src/rules/no-decode-without-verify/index.ts +162 -0
- package/src/rules/no-decode-without-verify/no-decode-without-verify.test.ts +76 -0
- package/src/rules/no-hardcoded-secret/index.ts +230 -0
- package/src/rules/no-hardcoded-secret/no-hardcoded-secret.test.ts +97 -0
- package/src/rules/no-sensitive-payload/index.ts +123 -0
- package/src/rules/no-sensitive-payload/no-sensitive-payload.test.ts +157 -0
- package/src/rules/no-timestamp-manipulation/index.ts +118 -0
- package/src/rules/no-timestamp-manipulation/no-timestamp-manipulation.test.ts +54 -0
- package/src/rules/no-weak-secret/index.ts +204 -0
- package/src/rules/no-weak-secret/no-weak-secret.test.ts +114 -0
- package/src/rules/require-algorithm-whitelist/index.ts +145 -0
- package/src/rules/require-algorithm-whitelist/require-algorithm-whitelist.test.ts +72 -0
- package/src/rules/require-audience-validation/index.ts +120 -0
- package/src/rules/require-audience-validation/require-audience-validation.test.ts +68 -0
- package/src/rules/require-expiration/index.ts +170 -0
- package/src/rules/require-expiration/require-expiration.test.ts +135 -0
- package/src/rules/require-issued-at/index.ts +143 -0
- package/src/rules/require-issued-at/require-issued-at.test.ts +64 -0
- package/src/rules/require-issuer-validation/index.ts +120 -0
- package/src/rules/require-issuer-validation/require-issuer-validation.test.ts +68 -0
- package/src/rules/require-max-age/index.ts +117 -0
- package/src/rules/require-max-age/require-max-age.test.ts +69 -0
- package/src/tests/coverage-dual-layer.test.ts +323 -0
- package/src/types/index.ts +107 -0
- package/src/utils/index.ts +285 -0
- package/src/utils/utils.test.ts +324 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ESLint Rule: require-audience-validation
|
|
9
|
+
*
|
|
10
|
+
* Mandates audience (aud) claim validation in JWT verify operations.
|
|
11
|
+
* Without audience validation, tokens intended for other services are accepted.
|
|
12
|
+
*
|
|
13
|
+
* CWE-287: Improper Authentication
|
|
14
|
+
*/
|
|
15
|
+
import type { TSESLint, TSESTree } from '@interlace/eslint-devkit';
|
|
16
|
+
import { createRule, formatLLMMessage, MessageIcons } from '@interlace/eslint-devkit';
|
|
17
|
+
import { isVerifyOperation, getOptionsArgument, hasOption } from '../../utils';
|
|
18
|
+
import type { RequireAudienceValidationOptions } from '../../types';
|
|
19
|
+
|
|
20
|
+
type MessageIds = 'missingAudienceValidation';
|
|
21
|
+
|
|
22
|
+
type RuleOptions = [RequireAudienceValidationOptions?];
|
|
23
|
+
|
|
24
|
+
export const requireAudienceValidation = createRule<RuleOptions, MessageIds>({
|
|
25
|
+
name: 'require-audience-validation',
|
|
26
|
+
meta: {
|
|
27
|
+
type: 'problem',
|
|
28
|
+
docs: {
|
|
29
|
+
url: 'https://github.com/ofri-peretz/eslint/blob/main/packages/eslint-plugin-jwt-security/docs/rules/require-audience-validation.md',
|
|
30
|
+
description:
|
|
31
|
+
'Require audience (aud) claim validation in JWT verify operations',
|
|
32
|
+
cwe: 'CWE-287',
|
|
33
|
+
cvss: 9.8,
|
|
34
|
+
},
|
|
35
|
+
fixable: undefined,
|
|
36
|
+
hasSuggestions: false,
|
|
37
|
+
messages: {
|
|
38
|
+
missingAudienceValidation: formatLLMMessage({
|
|
39
|
+
icon: MessageIcons.SECURITY,
|
|
40
|
+
issueName: 'Missing Audience Validation',
|
|
41
|
+
cwe: 'CWE-287',
|
|
42
|
+
description:
|
|
43
|
+
'JWT verification without audience validation accepts tokens intended for other services',
|
|
44
|
+
severity: 'MEDIUM',
|
|
45
|
+
fix: 'Add audience option: { audience: "https://api.example.com" }',
|
|
46
|
+
documentationLink: 'https://tools.ietf.org/html/rfc8725',
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
schema: [
|
|
50
|
+
{
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
knownAudiences: {
|
|
54
|
+
type: 'array',
|
|
55
|
+
items: { type: 'string' },
|
|
56
|
+
default: [],
|
|
57
|
+
description: 'Known valid audiences to suggest',
|
|
58
|
+
},
|
|
59
|
+
trustedSanitizers: {
|
|
60
|
+
type: 'array',
|
|
61
|
+
items: { type: 'string' },
|
|
62
|
+
default: [],
|
|
63
|
+
},
|
|
64
|
+
trustedAnnotations: {
|
|
65
|
+
type: 'array',
|
|
66
|
+
items: { type: 'string' },
|
|
67
|
+
default: [],
|
|
68
|
+
},
|
|
69
|
+
strictMode: {
|
|
70
|
+
type: 'boolean',
|
|
71
|
+
default: false,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
defaultOptions: [
|
|
79
|
+
{
|
|
80
|
+
knownAudiences: [],
|
|
81
|
+
trustedSanitizers: [],
|
|
82
|
+
trustedAnnotations: [],
|
|
83
|
+
strictMode: false,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
create(context: TSESLint.RuleContext<MessageIds, RuleOptions>) {
|
|
87
|
+
return {
|
|
88
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
89
|
+
if (!isVerifyOperation(node)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (node.arguments.length < 2) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const optionsArg = getOptionsArgument(node, 2);
|
|
98
|
+
|
|
99
|
+
// No options at all
|
|
100
|
+
if (!optionsArg) {
|
|
101
|
+
context.report({
|
|
102
|
+
node,
|
|
103
|
+
messageId: 'missingAudienceValidation',
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check for audience option
|
|
109
|
+
if (!hasOption(optionsArg, 'audience') && !hasOption(optionsArg, 'aud')) {
|
|
110
|
+
context.report({
|
|
111
|
+
node: optionsArg,
|
|
112
|
+
messageId: 'missingAudienceValidation',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export default requireAudienceValidation;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for require-audience-validation rule
|
|
3
|
+
*/
|
|
4
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
5
|
+
import { describe, it, afterAll } from 'vitest';
|
|
6
|
+
import parser from '@typescript-eslint/parser';
|
|
7
|
+
import { requireAudienceValidation } from './index';
|
|
8
|
+
|
|
9
|
+
RuleTester.afterAll = afterAll;
|
|
10
|
+
RuleTester.it = it;
|
|
11
|
+
RuleTester.itOnly = it.only;
|
|
12
|
+
RuleTester.describe = describe;
|
|
13
|
+
|
|
14
|
+
const ruleTester = new RuleTester({
|
|
15
|
+
languageOptions: {
|
|
16
|
+
parser,
|
|
17
|
+
ecmaVersion: 2022,
|
|
18
|
+
sourceType: 'module',
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('require-audience-validation', () => {
|
|
23
|
+
describe('Valid Code', () => {
|
|
24
|
+
ruleTester.run('valid - with audience', requireAudienceValidation, {
|
|
25
|
+
valid: [
|
|
26
|
+
{ code: `jwt.verify(token, secret, { audience: 'https://api.example.com' });` },
|
|
27
|
+
{ code: `jwt.verify(token, secret, { aud: 'api.example.com' });` },
|
|
28
|
+
{ code: `jwt.verify(token, secret, { audience: ['api', 'web'] });` },
|
|
29
|
+
{ code: `jwt.sign(payload, secret);` }, // sign not checked
|
|
30
|
+
// Only one argument - edge case
|
|
31
|
+
{ code: `jwt.verify(token);` },
|
|
32
|
+
// jwtVerify with audience
|
|
33
|
+
{ code: `jwtVerify(token, key, { audience: 'https://api.example.com' });` },
|
|
34
|
+
],
|
|
35
|
+
invalid: [],
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('Invalid Code', () => {
|
|
40
|
+
ruleTester.run('invalid - missing audience', requireAudienceValidation, {
|
|
41
|
+
valid: [],
|
|
42
|
+
invalid: [
|
|
43
|
+
{
|
|
44
|
+
code: `jwt.verify(token, secret);`,
|
|
45
|
+
errors: [{ messageId: 'missingAudienceValidation' }],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
code: `jwt.verify(token, secret, {});`,
|
|
49
|
+
errors: [{ messageId: 'missingAudienceValidation' }],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
code: `jwt.verify(token, secret, { issuer: 'auth.example.com' });`,
|
|
53
|
+
errors: [{ messageId: 'missingAudienceValidation' }],
|
|
54
|
+
},
|
|
55
|
+
// jwtVerify without audience
|
|
56
|
+
{
|
|
57
|
+
code: `jwtVerify(token, key);`,
|
|
58
|
+
errors: [{ messageId: 'missingAudienceValidation' }],
|
|
59
|
+
},
|
|
60
|
+
// jwtVerify with only issuer
|
|
61
|
+
{
|
|
62
|
+
code: `jwtVerify(token, key, { issuer: 'auth.example.com' });`,
|
|
63
|
+
errors: [{ messageId: 'missingAudienceValidation' }],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ESLint Rule: require-expiration
|
|
9
|
+
*
|
|
10
|
+
* Mandates expiration claim (exp) or expiresIn option in JWT sign operations.
|
|
11
|
+
* Tokens without expiration are valid forever, creating security risks.
|
|
12
|
+
*
|
|
13
|
+
* CWE-613: Insufficient Session Expiration
|
|
14
|
+
*
|
|
15
|
+
* @see https://tools.ietf.org/html/rfc8725
|
|
16
|
+
*/
|
|
17
|
+
import type { TSESLint, TSESTree } from '@interlace/eslint-devkit';
|
|
18
|
+
import { AST_NODE_TYPES, createRule, formatLLMMessage, MessageIcons } from '@interlace/eslint-devkit';
|
|
19
|
+
import {
|
|
20
|
+
isSignOperation,
|
|
21
|
+
getOptionsArgument,
|
|
22
|
+
hasOption,
|
|
23
|
+
} from '../../utils';
|
|
24
|
+
import type { RequireExpirationOptions } from '../../types';
|
|
25
|
+
|
|
26
|
+
type MessageIds =
|
|
27
|
+
| 'missingExpiration'
|
|
28
|
+
| 'addExpiration';
|
|
29
|
+
|
|
30
|
+
type RuleOptions = [RequireExpirationOptions?];
|
|
31
|
+
|
|
32
|
+
export const requireExpiration = createRule<RuleOptions, MessageIds>({
|
|
33
|
+
name: 'require-expiration',
|
|
34
|
+
meta: {
|
|
35
|
+
type: 'problem',
|
|
36
|
+
docs: {
|
|
37
|
+
url: 'https://github.com/ofri-peretz/eslint/blob/main/packages/eslint-plugin-jwt-security/docs/rules/require-expiration.md',
|
|
38
|
+
description:
|
|
39
|
+
'Require expiration claim (exp) or expiresIn option in JWT signing',
|
|
40
|
+
cwe: 'CWE-613',
|
|
41
|
+
cvss: 5.4,
|
|
42
|
+
},
|
|
43
|
+
hasSuggestions: true,
|
|
44
|
+
messages: {
|
|
45
|
+
missingExpiration: formatLLMMessage({
|
|
46
|
+
icon: MessageIcons.SECURITY,
|
|
47
|
+
issueName: 'Missing JWT Expiration',
|
|
48
|
+
cwe: 'CWE-613',
|
|
49
|
+
description:
|
|
50
|
+
'JWT without expiration is valid forever, increasing exposure window',
|
|
51
|
+
severity: 'MEDIUM',
|
|
52
|
+
fix: 'Add expiresIn: "1h" or exp claim to payload',
|
|
53
|
+
documentationLink: 'https://tools.ietf.org/html/rfc8725',
|
|
54
|
+
}),
|
|
55
|
+
addExpiration: formatLLMMessage({
|
|
56
|
+
icon: MessageIcons.INFO,
|
|
57
|
+
issueName: 'Add Expiration',
|
|
58
|
+
description: 'Add expiration to limit token lifetime',
|
|
59
|
+
severity: 'LOW',
|
|
60
|
+
fix: 'jwt.sign(payload, secret, { expiresIn: "1h" })',
|
|
61
|
+
documentationLink: 'https://www.npmjs.com/package/jsonwebtoken',
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
schema: [
|
|
65
|
+
{
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
maxExpirationSeconds: {
|
|
69
|
+
type: 'integer',
|
|
70
|
+
default: 86400,
|
|
71
|
+
description: 'Maximum allowed expiration time in seconds (24h default)',
|
|
72
|
+
},
|
|
73
|
+
trustedSanitizers: {
|
|
74
|
+
type: 'array',
|
|
75
|
+
items: { type: 'string' },
|
|
76
|
+
default: [],
|
|
77
|
+
},
|
|
78
|
+
trustedAnnotations: {
|
|
79
|
+
type: 'array',
|
|
80
|
+
items: { type: 'string' },
|
|
81
|
+
default: [],
|
|
82
|
+
},
|
|
83
|
+
strictMode: {
|
|
84
|
+
type: 'boolean',
|
|
85
|
+
default: false,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
defaultOptions: [
|
|
93
|
+
{
|
|
94
|
+
maxExpirationSeconds: 86400,
|
|
95
|
+
trustedSanitizers: [],
|
|
96
|
+
trustedAnnotations: [],
|
|
97
|
+
strictMode: false,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
create(context: TSESLint.RuleContext<MessageIds, RuleOptions>) {
|
|
101
|
+
/**
|
|
102
|
+
* Check if payload contains exp claim
|
|
103
|
+
*/
|
|
104
|
+
// oxlint-disable-next-line consistent-function-scoping
|
|
105
|
+
const payloadHasExp = (payloadNode: TSESTree.Node): boolean => {
|
|
106
|
+
if (payloadNode.type !== 'ObjectExpression') {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return payloadNode.properties.some(
|
|
111
|
+
(prop) =>
|
|
112
|
+
prop.type === 'Property' &&
|
|
113
|
+
prop.key.type === 'Identifier' &&
|
|
114
|
+
prop.key.name === 'exp'
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
120
|
+
// Only check sign operations
|
|
121
|
+
if (!isSignOperation(node)) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Need at least payload argument
|
|
126
|
+
if (node.arguments.length < 1) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const payloadArg = node.arguments[0];
|
|
131
|
+
const optionsArg = getOptionsArgument(node, 2);
|
|
132
|
+
|
|
133
|
+
// Check if payload has exp claim
|
|
134
|
+
if (payloadHasExp(payloadArg)) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Check if options has expiresIn
|
|
139
|
+
if (optionsArg && hasOption(optionsArg, 'expiresIn')) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Report missing expiration — suggest adding expiresIn to the options object
|
|
144
|
+
const optionsArg3 = node.arguments[2];
|
|
145
|
+
context.report({
|
|
146
|
+
node,
|
|
147
|
+
messageId: 'missingExpiration',
|
|
148
|
+
suggest: [
|
|
149
|
+
{
|
|
150
|
+
messageId: 'addExpiration',
|
|
151
|
+
fix(fixer) {
|
|
152
|
+
const sourceCode = context.sourceCode;
|
|
153
|
+
if (optionsArg3 && optionsArg3.type === AST_NODE_TYPES.ObjectExpression) {
|
|
154
|
+
// Options object exists but lacks expiresIn — insert property
|
|
155
|
+
const openBrace = sourceCode.getFirstToken(optionsArg3)!;
|
|
156
|
+
return fixer.insertTextAfter(openBrace, ' expiresIn: \'1h\',');
|
|
157
|
+
}
|
|
158
|
+
// No options arg — insert { expiresIn: '1h' } as third argument
|
|
159
|
+
const lastArg = node.arguments[node.arguments.length - 1];
|
|
160
|
+
return fixer.insertTextAfter(lastArg, ', { expiresIn: \'1h\' }');
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
export default requireExpiration;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for require-expiration rule
|
|
3
|
+
*/
|
|
4
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
5
|
+
import { describe, it, afterAll } from 'vitest';
|
|
6
|
+
import parser from '@typescript-eslint/parser';
|
|
7
|
+
import { requireExpiration } from './index';
|
|
8
|
+
|
|
9
|
+
RuleTester.afterAll = afterAll;
|
|
10
|
+
RuleTester.it = it;
|
|
11
|
+
RuleTester.itOnly = it.only;
|
|
12
|
+
RuleTester.describe = describe;
|
|
13
|
+
|
|
14
|
+
const ruleTester = new RuleTester({
|
|
15
|
+
languageOptions: {
|
|
16
|
+
parser,
|
|
17
|
+
ecmaVersion: 2022,
|
|
18
|
+
sourceType: 'module',
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('require-expiration', () => {
|
|
23
|
+
describe('Valid Code', () => {
|
|
24
|
+
ruleTester.run('valid - with expiration', requireExpiration, {
|
|
25
|
+
valid: [
|
|
26
|
+
// expiresIn option
|
|
27
|
+
{ code: `jwt.sign(payload, secret, { expiresIn: '1h' });` },
|
|
28
|
+
{ code: `jwt.sign(payload, secret, { expiresIn: 3600 });` },
|
|
29
|
+
// exp in payload
|
|
30
|
+
{ code: `jwt.sign({ sub: 'user', exp: 1234567890 }, secret);` },
|
|
31
|
+
{ code: `jwt.sign({ exp: Math.floor(Date.now()/1000) + 3600 }, secret);` },
|
|
32
|
+
// verify not checked
|
|
33
|
+
{ code: `jwt.verify(token, secret);` },
|
|
34
|
+
// signJWT with expiresIn
|
|
35
|
+
{ code: `signJWT(payload, key, { expiresIn: '1h' });` },
|
|
36
|
+
// Zero arguments - edge case (line 118 coverage)
|
|
37
|
+
{ code: `jwt.sign();` },
|
|
38
|
+
],
|
|
39
|
+
invalid: [],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('Invalid Code', () => {
|
|
44
|
+
ruleTester.run('invalid - no expiration', requireExpiration, {
|
|
45
|
+
valid: [],
|
|
46
|
+
invalid: [
|
|
47
|
+
{
|
|
48
|
+
code: `jwt.sign(payload, secret);`,
|
|
49
|
+
errors: [
|
|
50
|
+
{
|
|
51
|
+
messageId: 'missingExpiration',
|
|
52
|
+
suggestions: [
|
|
53
|
+
{
|
|
54
|
+
messageId: 'addExpiration',
|
|
55
|
+
output: `jwt.sign(payload, secret, { expiresIn: '1h' });`,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
code: `jwt.sign(payload, secret, {});`,
|
|
63
|
+
errors: [
|
|
64
|
+
{
|
|
65
|
+
messageId: 'missingExpiration',
|
|
66
|
+
suggestions: [
|
|
67
|
+
{
|
|
68
|
+
messageId: 'addExpiration',
|
|
69
|
+
output: `jwt.sign(payload, secret, { expiresIn: '1h',});`,
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
code: `jwt.sign(payload, secret, { algorithm: 'RS256' });`,
|
|
77
|
+
errors: [
|
|
78
|
+
{
|
|
79
|
+
messageId: 'missingExpiration',
|
|
80
|
+
suggestions: [
|
|
81
|
+
{
|
|
82
|
+
messageId: 'addExpiration',
|
|
83
|
+
output: `jwt.sign(payload, secret, { expiresIn: '1h', algorithm: 'RS256' });`,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
code: `jwt.sign({ sub: 'user', iat: Date.now() }, secret);`,
|
|
91
|
+
errors: [
|
|
92
|
+
{
|
|
93
|
+
messageId: 'missingExpiration',
|
|
94
|
+
suggestions: [
|
|
95
|
+
{
|
|
96
|
+
messageId: 'addExpiration',
|
|
97
|
+
output: `jwt.sign({ sub: 'user', iat: Date.now() }, secret, { expiresIn: '1h' });`,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
code: `sign(payload, key);`,
|
|
105
|
+
errors: [
|
|
106
|
+
{
|
|
107
|
+
messageId: 'missingExpiration',
|
|
108
|
+
suggestions: [
|
|
109
|
+
{
|
|
110
|
+
messageId: 'addExpiration',
|
|
111
|
+
output: `sign(payload, key, { expiresIn: '1h' });`,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
// signJWT without expiration
|
|
118
|
+
{
|
|
119
|
+
code: `signJWT({ sub: 'user' }, key, { algorithm: 'RS256' });`,
|
|
120
|
+
errors: [
|
|
121
|
+
{
|
|
122
|
+
messageId: 'missingExpiration',
|
|
123
|
+
suggestions: [
|
|
124
|
+
{
|
|
125
|
+
messageId: 'addExpiration',
|
|
126
|
+
output: `signJWT({ sub: 'user' }, key, { expiresIn: '1h', algorithm: 'RS256' });`,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Ofri Peretz
|
|
3
|
+
* Licensed under the MIT License. Use of this source code is governed by the
|
|
4
|
+
* MIT license that can be found in the LICENSE file.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ESLint Rule: require-issued-at
|
|
9
|
+
*
|
|
10
|
+
* Mandates the `iat` (issued at) claim in JWT tokens for freshness validation.
|
|
11
|
+
* Without `iat`, tokens cannot be validated for age, enabling the
|
|
12
|
+
* "Back to the Future" replay attack (LightSEC 2025).
|
|
13
|
+
*
|
|
14
|
+
* CWE-294: Authentication Bypass by Capture-replay
|
|
15
|
+
*
|
|
16
|
+
* @see https://securitypattern.com/post/jwt-back-to-the-future
|
|
17
|
+
*/
|
|
18
|
+
import type { TSESLint, TSESTree } from '@interlace/eslint-devkit';
|
|
19
|
+
import { createRule, formatLLMMessage, MessageIcons } from '@interlace/eslint-devkit';
|
|
20
|
+
import { isSignOperation, getOptionsArgument, hasOption } from '../../utils';
|
|
21
|
+
import type { JwtRuleOptions } from '../../types';
|
|
22
|
+
|
|
23
|
+
type MessageIds = 'missingIssuedAt';
|
|
24
|
+
|
|
25
|
+
type RuleOptions = [JwtRuleOptions?];
|
|
26
|
+
|
|
27
|
+
export const requireIssuedAt = createRule<RuleOptions, MessageIds>({
|
|
28
|
+
name: 'require-issued-at',
|
|
29
|
+
meta: {
|
|
30
|
+
type: 'problem',
|
|
31
|
+
docs: {
|
|
32
|
+
url: 'https://github.com/ofri-peretz/eslint/blob/main/packages/eslint-plugin-jwt-security/docs/rules/require-issued-at.md',
|
|
33
|
+
description:
|
|
34
|
+
'Require iat (issued at) claim for token freshness validation',
|
|
35
|
+
cwe: 'CWE-294',
|
|
36
|
+
cvss: 5,
|
|
37
|
+
},
|
|
38
|
+
fixable: undefined,
|
|
39
|
+
hasSuggestions: false,
|
|
40
|
+
messages: {
|
|
41
|
+
missingIssuedAt: formatLLMMessage({
|
|
42
|
+
icon: MessageIcons.SECURITY,
|
|
43
|
+
issueName: 'Missing Issued At Claim',
|
|
44
|
+
cwe: 'CWE-294',
|
|
45
|
+
description:
|
|
46
|
+
'JWT without iat claim cannot be validated for freshness, enabling replay attacks',
|
|
47
|
+
severity: 'MEDIUM',
|
|
48
|
+
fix: 'Add iat claim to payload or use library option that adds it automatically',
|
|
49
|
+
documentationLink: 'https://securitypattern.com/post/jwt-back-to-the-future',
|
|
50
|
+
}),
|
|
51
|
+
},
|
|
52
|
+
schema: [
|
|
53
|
+
{
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
trustedSanitizers: {
|
|
57
|
+
type: 'array',
|
|
58
|
+
items: { type: 'string' },
|
|
59
|
+
default: [],
|
|
60
|
+
},
|
|
61
|
+
trustedAnnotations: {
|
|
62
|
+
type: 'array',
|
|
63
|
+
items: { type: 'string' },
|
|
64
|
+
default: [],
|
|
65
|
+
},
|
|
66
|
+
strictMode: {
|
|
67
|
+
type: 'boolean',
|
|
68
|
+
default: false,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
defaultOptions: [
|
|
76
|
+
{
|
|
77
|
+
trustedSanitizers: [],
|
|
78
|
+
trustedAnnotations: [],
|
|
79
|
+
strictMode: false,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
create(context: TSESLint.RuleContext<MessageIds, RuleOptions>) {
|
|
83
|
+
/**
|
|
84
|
+
* Check if payload contains iat claim
|
|
85
|
+
*/
|
|
86
|
+
// oxlint-disable-next-line consistent-function-scoping
|
|
87
|
+
const payloadHasIat = (payloadNode: TSESTree.Node): boolean => {
|
|
88
|
+
if (payloadNode.type !== 'ObjectExpression') {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return payloadNode.properties.some(
|
|
93
|
+
(prop) =>
|
|
94
|
+
prop.type === 'Property' &&
|
|
95
|
+
prop.key.type === 'Identifier' &&
|
|
96
|
+
prop.key.name === 'iat'
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
102
|
+
// Only check sign operations
|
|
103
|
+
if (!isSignOperation(node)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Need at least payload argument
|
|
108
|
+
if (node.arguments.length < 1) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const payloadArg = node.arguments[0];
|
|
113
|
+
const optionsArg = getOptionsArgument(node, 2);
|
|
114
|
+
|
|
115
|
+
// Check if payload has iat claim
|
|
116
|
+
if (payloadHasIat(payloadArg)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check if options has noTimestamp: false (meaning iat IS added)
|
|
121
|
+
// jsonwebtoken adds iat by default unless noTimestamp: true
|
|
122
|
+
if (optionsArg && hasOption(optionsArg, 'noTimestamp')) {
|
|
123
|
+
// If noTimestamp is explicitly set, we flag it (they're disabling iat)
|
|
124
|
+
context.report({
|
|
125
|
+
node: optionsArg,
|
|
126
|
+
messageId: 'missingIssuedAt',
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// For explicit payload objects without iat we intentionally do not
|
|
132
|
+
// report — jsonwebtoken adds iat by default. (A dead empty `if` that
|
|
133
|
+
// re-checked !payloadHasIat(payloadArg) — always true after the early
|
|
134
|
+
// return above — was removed as unreachable-by-construction.)
|
|
135
|
+
|
|
136
|
+
// Note: jsonwebtoken adds iat by default, so we don't flag by default
|
|
137
|
+
// This rule primarily catches explicit noTimestamp: true usage
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
export default requireIssuedAt;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for require-issued-at rule
|
|
3
|
+
*/
|
|
4
|
+
import { RuleTester } from '@typescript-eslint/rule-tester';
|
|
5
|
+
import { describe, it, afterAll } from 'vitest';
|
|
6
|
+
import parser from '@typescript-eslint/parser';
|
|
7
|
+
import { requireIssuedAt } from './index';
|
|
8
|
+
|
|
9
|
+
RuleTester.afterAll = afterAll;
|
|
10
|
+
RuleTester.it = it;
|
|
11
|
+
RuleTester.itOnly = it.only;
|
|
12
|
+
RuleTester.describe = describe;
|
|
13
|
+
|
|
14
|
+
const ruleTester = new RuleTester({
|
|
15
|
+
languageOptions: {
|
|
16
|
+
parser,
|
|
17
|
+
ecmaVersion: 2022,
|
|
18
|
+
sourceType: 'module',
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('require-issued-at', () => {
|
|
23
|
+
describe('Valid Code', () => {
|
|
24
|
+
ruleTester.run('valid - with iat', requireIssuedAt, {
|
|
25
|
+
valid: [
|
|
26
|
+
// iat in payload
|
|
27
|
+
{ code: `jwt.sign({ sub: 'user', iat: Date.now() }, secret);` },
|
|
28
|
+
// Default behavior (jsonwebtoken adds iat automatically)
|
|
29
|
+
{ code: `jwt.sign(payload, secret);` },
|
|
30
|
+
{ code: `jwt.sign(payload, secret, { expiresIn: '1h' });` },
|
|
31
|
+
// Verify not checked
|
|
32
|
+
{ code: `jwt.verify(token, secret);` },
|
|
33
|
+
// No arguments - edge case
|
|
34
|
+
{ code: `sign();` },
|
|
35
|
+
// Variable payload with iat
|
|
36
|
+
{ code: `jwt.sign({ iat: Math.floor(Date.now() / 1000), sub: 'user' }, secret);` },
|
|
37
|
+
],
|
|
38
|
+
invalid: [],
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('Invalid Code', () => {
|
|
43
|
+
ruleTester.run('invalid - noTimestamp disables iat', requireIssuedAt, {
|
|
44
|
+
valid: [],
|
|
45
|
+
invalid: [
|
|
46
|
+
// noTimestamp: true explicitly disables iat
|
|
47
|
+
{
|
|
48
|
+
code: `jwt.sign({ sub: 'user' }, secret, { noTimestamp: true });`,
|
|
49
|
+
errors: [{ messageId: 'missingIssuedAt' }],
|
|
50
|
+
},
|
|
51
|
+
// noTimestamp with other options
|
|
52
|
+
{
|
|
53
|
+
code: `jwt.sign(payload, secret, { expiresIn: '1h', noTimestamp: true });`,
|
|
54
|
+
errors: [{ messageId: 'missingIssuedAt' }],
|
|
55
|
+
},
|
|
56
|
+
// SignJWT with noTimestamp
|
|
57
|
+
{
|
|
58
|
+
code: `signJWT(payload, key, { noTimestamp: true });`,
|
|
59
|
+
errors: [{ messageId: 'missingIssuedAt' }],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|