@zimbra/eslint-plugin-preact-i18n 2.0.0 → 2.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.
- package/README.md +1 -0
- package/docs/rules/no-unsafe-template.md +62 -0
- package/lib/index.js +5 -1
- package/lib/processors/translation-json.js +16 -0
- package/lib/rules/no-unsafe-template.js +191 -0
- package/package.json +2 -1
- package/src/index.js +5 -1
- package/src/processors/translation-json.js +15 -0
- package/src/rules/no-unsafe-template.js +229 -0
- package/test/src/index.spec.js +4 -2
- package/test/src/rules/no-unsafe-template.spec.js +190 -0
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ npm i -D eslint-plugin-preact-i18n
|
|
|
16
16
|
* **[preact-i18n/no-text-as-children](docs/rules/no-text-as-children.md)**: Verify that you have no text node children in your preact code.
|
|
17
17
|
* **[preact-i18n/no-text-as-attribute](docs/rules/no-text-as-attribute.md)**: Verify that you have no text in some attributes in your react components. List of attributes provided in the config.
|
|
18
18
|
* **[preact-i18n/no-unknown-key](docs/rules/no-unknown-key.md)**: Verify that all translation keys you use are present in your defined translation files.
|
|
19
|
+
* **[preact-i18n/no-unsafe-template](docs/rules/no-unsafe-template.md)**: Report unsafe dynamic markup in JSON translation templates.
|
|
19
20
|
|
|
20
21
|
## Config
|
|
21
22
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Disallow unsafe translation templates (preact-i18n/no-unsafe-template)
|
|
2
|
+
|
|
3
|
+
This rule reports markup in JSON translation values that could allow script injection:
|
|
4
|
+
|
|
5
|
+
- Dynamic event handler attributes
|
|
6
|
+
- Unquoted dynamic attributes
|
|
7
|
+
- Dynamic style attributes
|
|
8
|
+
- `javascript:` links
|
|
9
|
+
- `data:text/html` URIs
|
|
10
|
+
- Dynamic SVG attributes
|
|
11
|
+
|
|
12
|
+
The rule checks decoded JSON values, so attributes containing escaped double quotes are also validated.
|
|
13
|
+
|
|
14
|
+
## Configuration
|
|
15
|
+
|
|
16
|
+
Use the included processor for translation JSON files:
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import preactI18n from '@zimbra/eslint-plugin-preact-i18n';
|
|
20
|
+
|
|
21
|
+
export default [
|
|
22
|
+
{
|
|
23
|
+
files: ['**/intl/**/*.json', '**/i18n/**/*.json', '**/translations/**/*.json'],
|
|
24
|
+
plugins: {
|
|
25
|
+
'preact-i18n': preactI18n
|
|
26
|
+
},
|
|
27
|
+
processor: 'preact-i18n/translation-json',
|
|
28
|
+
rules: {
|
|
29
|
+
'preact-i18n/no-unsafe-template': 'error'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
];
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
If the JSON files already use the `eslint-plugin-i18n-json` processor, keep that processor and enable this rule in the same configuration. The rule supports both processors.
|
|
36
|
+
|
|
37
|
+
## Examples
|
|
38
|
+
|
|
39
|
+
Examples of **incorrect** translations:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"event": "<button onclick=\"{{handler}}\">Open</button>",
|
|
44
|
+
"link": "<a href={{url}}>Open</a>",
|
|
45
|
+
"style": "<span style=\"color: {{color}}\">Text</span>",
|
|
46
|
+
"protocol": "<a href=\"javascript:alert(1)\">Open</a>"
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Examples of **correct** translations:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"text": "Hello {{name}}",
|
|
55
|
+
"link": "<a href=\"{{url}}\">Open</a>",
|
|
56
|
+
"image": "<img src=\"/images/logo.svg\">"
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Options
|
|
61
|
+
|
|
62
|
+
This rule has no options.
|
package/lib/index.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
module.exports = {
|
|
4
|
+
processors: {
|
|
5
|
+
'translation-json': require('./processors/translation-json')
|
|
6
|
+
},
|
|
4
7
|
rules: {
|
|
5
8
|
'no-unknown-key': require('./rules/no-unknown-key'),
|
|
6
9
|
'no-text-as-attribute': require('./rules/no-text-as-attribute'),
|
|
7
10
|
'no-text-as-children': require('./rules/no-text-as-children'),
|
|
8
|
-
'no-missing-template-field': require('./rules/no-missing-template-field')
|
|
11
|
+
'no-missing-template-field': require('./rules/no-missing-template-field'),
|
|
12
|
+
'no-unsafe-template': require('./rules/no-unsafe-template')
|
|
9
13
|
}
|
|
10
14
|
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const SOURCE_PREFIX = 'preact-i18n-json:';
|
|
4
|
+
module.exports = {
|
|
5
|
+
meta: {
|
|
6
|
+
name: 'translation-json'
|
|
7
|
+
},
|
|
8
|
+
preprocess(source) {
|
|
9
|
+
const encodedSource = Buffer.from(source, 'utf8').toString('base64');
|
|
10
|
+
return [`/* ${SOURCE_PREFIX}${encodedSource} */\n`];
|
|
11
|
+
},
|
|
12
|
+
postprocess(messageLists) {
|
|
13
|
+
return messageLists[0] || [];
|
|
14
|
+
},
|
|
15
|
+
supportsAutofix: false
|
|
16
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
|
|
4
|
+
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
|
|
5
|
+
function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
|
|
6
|
+
function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
|
|
7
|
+
function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const SOURCE_PREFIX = 'preact-i18n-json:';
|
|
10
|
+
const UNSAFE_PATTERNS = [{
|
|
11
|
+
pattern: /\bon[a-z]+\s*=\s*(?:["'][^"']*)?\{\{/gi,
|
|
12
|
+
risk: 'HIGH',
|
|
13
|
+
description: 'Event handler with a dynamic value detected',
|
|
14
|
+
fix: 'Avoid dynamic event handlers. Use class-based event delegation instead.'
|
|
15
|
+
}, {
|
|
16
|
+
pattern: /style\s*=\s*["'][^"']*\{\{[^}]+\}\}[^"']*["']/gi,
|
|
17
|
+
risk: 'HIGH',
|
|
18
|
+
description: 'Dynamic CSS in a style attribute detected',
|
|
19
|
+
fix: 'Use class-based styling instead of dynamic style attributes.'
|
|
20
|
+
}, {
|
|
21
|
+
pattern: /(?:^|[\s<])(href|src|action)\s*=\s*["']?\s*javascript:/gi,
|
|
22
|
+
risk: 'HIGH',
|
|
23
|
+
description: 'JavaScript protocol in a URL attribute detected',
|
|
24
|
+
fix: 'Never use the javascript: protocol. Use a valid URL instead.'
|
|
25
|
+
}, {
|
|
26
|
+
pattern: /(?:^|[\s<])(href|src|action)\s*=\s*["']?\s*data:text\/html/gi,
|
|
27
|
+
risk: 'HIGH',
|
|
28
|
+
description: 'Data URI with HTML content detected',
|
|
29
|
+
fix: 'Avoid data:text/html URIs. Use a safe, allowlisted protocol.'
|
|
30
|
+
}, {
|
|
31
|
+
pattern: /<svg(?:[^>]*=\s*[^{<]*\{\{[^>]*\/?>|[\s\S]*?<[^>]*=\s*[^{<]*\{\{[^>]*>[\s\S]*?<\/svg>)/gi,
|
|
32
|
+
risk: 'MEDIUM',
|
|
33
|
+
description: 'SVG element with dynamic attributes detected',
|
|
34
|
+
fix: 'Avoid dynamic SVG attributes.'
|
|
35
|
+
}, {
|
|
36
|
+
pattern: /(href|src|action)\s*=\s*\{\{/gi,
|
|
37
|
+
risk: 'HIGH',
|
|
38
|
+
description: 'URL attribute with an unquoted dynamic value detected',
|
|
39
|
+
fix: 'Always quote URL attributes, for example href="{{url}}".'
|
|
40
|
+
}, {
|
|
41
|
+
pattern: /([a-z-]+)\s*=\s*\{\{[^}]+\}\}(?!\s*["'])/gi,
|
|
42
|
+
risk: 'HIGH',
|
|
43
|
+
description: 'Unquoted attribute with a dynamic value detected',
|
|
44
|
+
fix: 'Always quote dynamic attributes, for example title="{{title}}".'
|
|
45
|
+
}];
|
|
46
|
+
function getJsonSource(context, node) {
|
|
47
|
+
const filename = context.physicalFilename || context.filename;
|
|
48
|
+
if (path.extname(filename).toLowerCase() !== '.json') {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const comments = node.comments || [];
|
|
52
|
+
const encodedComment = comments.find(comment => comment.value.trim().startsWith(SOURCE_PREFIX));
|
|
53
|
+
if (encodedComment) {
|
|
54
|
+
const encodedSource = encodedComment.value.trim().slice(SOURCE_PREFIX.length);
|
|
55
|
+
return Buffer.from(encodedSource, 'base64').toString('utf8');
|
|
56
|
+
}
|
|
57
|
+
if (comments.length >= 2) {
|
|
58
|
+
return comments[0].value.trim();
|
|
59
|
+
}
|
|
60
|
+
const source = context.sourceCode.text;
|
|
61
|
+
return source.trim().startsWith('{') || source.trim().startsWith('[') ? source : null;
|
|
62
|
+
}
|
|
63
|
+
function getLocation(source, index) {
|
|
64
|
+
const precedingSource = source.slice(0, index);
|
|
65
|
+
const line = precedingSource.split('\n').length;
|
|
66
|
+
const lastNewline = precedingSource.lastIndexOf('\n');
|
|
67
|
+
return {
|
|
68
|
+
line,
|
|
69
|
+
column: index - lastNewline - 1
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function readJsonString(source, start) {
|
|
73
|
+
let escaped = false;
|
|
74
|
+
for (let index = start + 1; index < source.length; index++) {
|
|
75
|
+
const character = source[index];
|
|
76
|
+
if (escaped) {
|
|
77
|
+
escaped = false;
|
|
78
|
+
} else if (character === '\\') {
|
|
79
|
+
escaped = true;
|
|
80
|
+
} else if (character === '"') {
|
|
81
|
+
const rawValue = source.slice(start, index + 1);
|
|
82
|
+
try {
|
|
83
|
+
return {
|
|
84
|
+
end: index + 1,
|
|
85
|
+
value: JSON.parse(rawValue)
|
|
86
|
+
};
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
function getTranslationStrings(source) {
|
|
95
|
+
const strings = [];
|
|
96
|
+
for (let index = 0; index < source.length; index++) {
|
|
97
|
+
if (source[index] !== '"') {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const parsedString = readJsonString(source, index);
|
|
101
|
+
if (!parsedString) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
let nextIndex = parsedString.end;
|
|
105
|
+
while (/\s/.test(source[nextIndex])) {
|
|
106
|
+
nextIndex++;
|
|
107
|
+
}
|
|
108
|
+
if (source[nextIndex] !== ':') {
|
|
109
|
+
strings.push({
|
|
110
|
+
index,
|
|
111
|
+
value: parsedString.value
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
index = parsedString.end - 1;
|
|
115
|
+
}
|
|
116
|
+
return strings;
|
|
117
|
+
}
|
|
118
|
+
function findIssues(value) {
|
|
119
|
+
const issues = [];
|
|
120
|
+
UNSAFE_PATTERNS.forEach(patternDefinition => {
|
|
121
|
+
const regex = new RegExp(patternDefinition.pattern.source, patternDefinition.pattern.flags);
|
|
122
|
+
let match;
|
|
123
|
+
while ((match = regex.exec(value)) !== null) {
|
|
124
|
+
const end = match.index + match[0].length;
|
|
125
|
+
const overlapsExistingIssue = issues.some(issue => {
|
|
126
|
+
const overlaps = match.index < issue.end && end > issue.index;
|
|
127
|
+
if (!overlaps) return false;
|
|
128
|
+
|
|
129
|
+
// Only deduplicate the URL-specific pattern with the generic unquoted attribute pattern
|
|
130
|
+
const isURLPattern = patternDefinition.description === 'URL attribute with an unquoted dynamic value detected';
|
|
131
|
+
const isGenericPattern = patternDefinition.description === 'Unquoted attribute with a dynamic value detected';
|
|
132
|
+
const issueIsURLPattern = issue.description === 'URL attribute with an unquoted dynamic value detected';
|
|
133
|
+
const issueIsGenericPattern = issue.description === 'Unquoted attribute with a dynamic value detected';
|
|
134
|
+
return (isURLPattern || isGenericPattern) && (issueIsURLPattern || issueIsGenericPattern);
|
|
135
|
+
});
|
|
136
|
+
if (!overlapsExistingIssue) {
|
|
137
|
+
issues.push(_objectSpread(_objectSpread({}, patternDefinition), {}, {
|
|
138
|
+
index: match.index,
|
|
139
|
+
end
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return issues;
|
|
145
|
+
}
|
|
146
|
+
module.exports = {
|
|
147
|
+
meta: {
|
|
148
|
+
type: 'problem',
|
|
149
|
+
docs: {
|
|
150
|
+
description: 'disallows unsafe dynamic markup in translation templates',
|
|
151
|
+
category: 'Possible errors'
|
|
152
|
+
},
|
|
153
|
+
schema: [],
|
|
154
|
+
messages: {
|
|
155
|
+
unsafeTemplate: '{{risk}} risk: {{description}}. {{fix}}'
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
create(context) {
|
|
159
|
+
return {
|
|
160
|
+
Program(node) {
|
|
161
|
+
const source = getJsonSource(context, node);
|
|
162
|
+
if (!source) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
JSON.parse(source);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
getTranslationStrings(source).forEach(translation => {
|
|
171
|
+
findIssues(translation.value).forEach(issue => {
|
|
172
|
+
const start = getLocation(source, translation.index);
|
|
173
|
+
context.report({
|
|
174
|
+
loc: {
|
|
175
|
+
start
|
|
176
|
+
},
|
|
177
|
+
messageId: 'unsafeTemplate',
|
|
178
|
+
data: {
|
|
179
|
+
risk: issue.risk,
|
|
180
|
+
description: issue.description,
|
|
181
|
+
fix: issue.fix
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
module.exports.findIssues = findIssues;
|
|
191
|
+
module.exports.getTranslationStrings = getTranslationStrings;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zimbra/eslint-plugin-preact-i18n",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "eslint plugin for users of the preact-i18n library",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eslint",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"@babel/register": "^7.0.0",
|
|
47
47
|
"@zimbra/eslint-config": "2.3.1-PREAPPS-9700.f1b2ff0.0",
|
|
48
48
|
"eslint": "^9.39.3",
|
|
49
|
+
"eslint-plugin-i18n-json": "^4.0.1",
|
|
49
50
|
"husky": "^9.1.7",
|
|
50
51
|
"mocha": "^11.7.6",
|
|
51
52
|
"rimraf": "^5.0.10",
|
package/src/index.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
module.exports = {
|
|
2
|
+
processors: {
|
|
3
|
+
'translation-json': require('./processors/translation-json')
|
|
4
|
+
},
|
|
2
5
|
rules: {
|
|
3
6
|
'no-unknown-key': require('./rules/no-unknown-key'),
|
|
4
7
|
'no-text-as-attribute': require('./rules/no-text-as-attribute'),
|
|
5
8
|
'no-text-as-children': require('./rules/no-text-as-children'),
|
|
6
|
-
'no-missing-template-field': require('./rules/no-missing-template-field')
|
|
9
|
+
'no-missing-template-field': require('./rules/no-missing-template-field'),
|
|
10
|
+
'no-unsafe-template': require('./rules/no-unsafe-template')
|
|
7
11
|
}
|
|
8
12
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const SOURCE_PREFIX = 'preact-i18n-json:';
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
meta: {
|
|
5
|
+
name: 'translation-json'
|
|
6
|
+
},
|
|
7
|
+
preprocess(source) {
|
|
8
|
+
const encodedSource = Buffer.from(source, 'utf8').toString('base64');
|
|
9
|
+
return [`/* ${SOURCE_PREFIX}${encodedSource} */\n`];
|
|
10
|
+
},
|
|
11
|
+
postprocess(messageLists) {
|
|
12
|
+
return messageLists[0] || [];
|
|
13
|
+
},
|
|
14
|
+
supportsAutofix: false
|
|
15
|
+
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
|
|
3
|
+
const SOURCE_PREFIX = 'preact-i18n-json:';
|
|
4
|
+
const UNSAFE_PATTERNS = [
|
|
5
|
+
{
|
|
6
|
+
pattern: /\bon[a-z]+\s*=\s*(?:["'][^"']*)?\{\{/gi,
|
|
7
|
+
risk: 'HIGH',
|
|
8
|
+
description: 'Event handler with a dynamic value detected',
|
|
9
|
+
fix: 'Avoid dynamic event handlers. Use class-based event delegation instead.'
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
pattern: /style\s*=\s*["'][^"']*\{\{[^}]+\}\}[^"']*["']/gi,
|
|
13
|
+
risk: 'HIGH',
|
|
14
|
+
description: 'Dynamic CSS in a style attribute detected',
|
|
15
|
+
fix: 'Use class-based styling instead of dynamic style attributes.'
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
pattern: /(?:^|[\s<])(href|src|action)\s*=\s*["']?\s*javascript:/gi,
|
|
19
|
+
risk: 'HIGH',
|
|
20
|
+
description: 'JavaScript protocol in a URL attribute detected',
|
|
21
|
+
fix: 'Never use the javascript: protocol. Use a valid URL instead.'
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
pattern: /(?:^|[\s<])(href|src|action)\s*=\s*["']?\s*data:text\/html/gi,
|
|
25
|
+
risk: 'HIGH',
|
|
26
|
+
description: 'Data URI with HTML content detected',
|
|
27
|
+
fix: 'Avoid data:text/html URIs. Use a safe, allowlisted protocol.'
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
pattern:
|
|
31
|
+
/<svg(?:[^>]*=\s*[^{<]*\{\{[^>]*\/?>|[\s\S]*?<[^>]*=\s*[^{<]*\{\{[^>]*>[\s\S]*?<\/svg>)/gi,
|
|
32
|
+
risk: 'MEDIUM',
|
|
33
|
+
description: 'SVG element with dynamic attributes detected',
|
|
34
|
+
fix: 'Avoid dynamic SVG attributes.'
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
pattern: /(href|src|action)\s*=\s*\{\{/gi,
|
|
38
|
+
risk: 'HIGH',
|
|
39
|
+
description: 'URL attribute with an unquoted dynamic value detected',
|
|
40
|
+
fix: 'Always quote URL attributes, for example href="{{url}}".'
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
pattern: /([a-z-]+)\s*=\s*\{\{[^}]+\}\}(?!\s*["'])/gi,
|
|
44
|
+
risk: 'HIGH',
|
|
45
|
+
description: 'Unquoted attribute with a dynamic value detected',
|
|
46
|
+
fix: 'Always quote dynamic attributes, for example title="{{title}}".'
|
|
47
|
+
}
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function getJsonSource(context, node) {
|
|
51
|
+
const filename = context.physicalFilename || context.filename;
|
|
52
|
+
if (path.extname(filename).toLowerCase() !== '.json') {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const comments = node.comments || [];
|
|
57
|
+
const encodedComment = comments.find(comment => comment.value.trim().startsWith(SOURCE_PREFIX));
|
|
58
|
+
|
|
59
|
+
if (encodedComment) {
|
|
60
|
+
const encodedSource = encodedComment.value.trim().slice(SOURCE_PREFIX.length);
|
|
61
|
+
return Buffer.from(encodedSource, 'base64').toString('utf8');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (comments.length >= 2) {
|
|
65
|
+
return comments[0].value.trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const source = context.sourceCode.text;
|
|
69
|
+
return source.trim().startsWith('{') || source.trim().startsWith('[') ? source : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getLocation(source, index) {
|
|
73
|
+
const precedingSource = source.slice(0, index);
|
|
74
|
+
const line = precedingSource.split('\n').length;
|
|
75
|
+
const lastNewline = precedingSource.lastIndexOf('\n');
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
line,
|
|
79
|
+
column: index - lastNewline - 1
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readJsonString(source, start) {
|
|
84
|
+
let escaped = false;
|
|
85
|
+
|
|
86
|
+
for (let index = start + 1; index < source.length; index++) {
|
|
87
|
+
const character = source[index];
|
|
88
|
+
|
|
89
|
+
if (escaped) {
|
|
90
|
+
escaped = false;
|
|
91
|
+
} else if (character === '\\') {
|
|
92
|
+
escaped = true;
|
|
93
|
+
} else if (character === '"') {
|
|
94
|
+
const rawValue = source.slice(start, index + 1);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
return {
|
|
98
|
+
end: index + 1,
|
|
99
|
+
value: JSON.parse(rawValue)
|
|
100
|
+
};
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getTranslationStrings(source) {
|
|
111
|
+
const strings = [];
|
|
112
|
+
|
|
113
|
+
for (let index = 0; index < source.length; index++) {
|
|
114
|
+
if (source[index] !== '"') {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const parsedString = readJsonString(source, index);
|
|
119
|
+
if (!parsedString) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let nextIndex = parsedString.end;
|
|
124
|
+
while (/\s/.test(source[nextIndex])) {
|
|
125
|
+
nextIndex++;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (source[nextIndex] !== ':') {
|
|
129
|
+
strings.push({
|
|
130
|
+
index,
|
|
131
|
+
value: parsedString.value
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
index = parsedString.end - 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return strings;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function findIssues(value) {
|
|
142
|
+
const issues = [];
|
|
143
|
+
|
|
144
|
+
UNSAFE_PATTERNS.forEach(patternDefinition => {
|
|
145
|
+
const regex = new RegExp(patternDefinition.pattern.source, patternDefinition.pattern.flags);
|
|
146
|
+
let match;
|
|
147
|
+
|
|
148
|
+
while ((match = regex.exec(value)) !== null) {
|
|
149
|
+
const end = match.index + match[0].length;
|
|
150
|
+
const overlapsExistingIssue = issues.some(issue => {
|
|
151
|
+
const overlaps = match.index < issue.end && end > issue.index;
|
|
152
|
+
if (!overlaps) return false;
|
|
153
|
+
|
|
154
|
+
// Only deduplicate the URL-specific pattern with the generic unquoted attribute pattern
|
|
155
|
+
const isURLPattern =
|
|
156
|
+
patternDefinition.description === 'URL attribute with an unquoted dynamic value detected';
|
|
157
|
+
const isGenericPattern =
|
|
158
|
+
patternDefinition.description === 'Unquoted attribute with a dynamic value detected';
|
|
159
|
+
const issueIsURLPattern =
|
|
160
|
+
issue.description === 'URL attribute with an unquoted dynamic value detected';
|
|
161
|
+
const issueIsGenericPattern =
|
|
162
|
+
issue.description === 'Unquoted attribute with a dynamic value detected';
|
|
163
|
+
|
|
164
|
+
return (isURLPattern || isGenericPattern) && (issueIsURLPattern || issueIsGenericPattern);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (!overlapsExistingIssue) {
|
|
168
|
+
issues.push({
|
|
169
|
+
...patternDefinition,
|
|
170
|
+
index: match.index,
|
|
171
|
+
end
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return issues;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
meta: {
|
|
182
|
+
type: 'problem',
|
|
183
|
+
docs: {
|
|
184
|
+
description: 'disallows unsafe dynamic markup in translation templates',
|
|
185
|
+
category: 'Possible errors'
|
|
186
|
+
},
|
|
187
|
+
schema: [],
|
|
188
|
+
messages: {
|
|
189
|
+
unsafeTemplate: '{{risk}} risk: {{description}}. {{fix}}'
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
create(context) {
|
|
193
|
+
return {
|
|
194
|
+
Program(node) {
|
|
195
|
+
const source = getJsonSource(context, node);
|
|
196
|
+
if (!source) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
JSON.parse(source);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
getTranslationStrings(source).forEach(translation => {
|
|
207
|
+
findIssues(translation.value).forEach(issue => {
|
|
208
|
+
const start = getLocation(source, translation.index);
|
|
209
|
+
|
|
210
|
+
context.report({
|
|
211
|
+
loc: {
|
|
212
|
+
start
|
|
213
|
+
},
|
|
214
|
+
messageId: 'unsafeTemplate',
|
|
215
|
+
data: {
|
|
216
|
+
risk: issue.risk,
|
|
217
|
+
description: issue.description,
|
|
218
|
+
fix: issue.fix
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
module.exports.findIssues = findIssues;
|
|
229
|
+
module.exports.getTranslationStrings = getTranslationStrings;
|
package/test/src/index.spec.js
CHANGED
|
@@ -2,11 +2,13 @@ import assert from 'node:assert';
|
|
|
2
2
|
import api from '../../src';
|
|
3
3
|
|
|
4
4
|
describe('module exports', () => {
|
|
5
|
-
it('should
|
|
6
|
-
assert.deepEqual(Object.keys(api), ['rules']);
|
|
5
|
+
it('should expose the rules and JSON processor', () => {
|
|
6
|
+
assert.deepEqual(Object.keys(api), ['processors', 'rules']);
|
|
7
|
+
assert.ok(api.processors['translation-json']);
|
|
7
8
|
assert.ok(api.rules['no-unknown-key']);
|
|
8
9
|
assert.ok(api.rules['no-text-as-children']);
|
|
9
10
|
assert.ok(api.rules['no-text-as-attribute']);
|
|
10
11
|
assert.ok(api.rules['no-missing-template-field']);
|
|
12
|
+
assert.ok(api.rules['no-unsafe-template']);
|
|
11
13
|
});
|
|
12
14
|
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { Linter } from 'eslint';
|
|
3
|
+
import i18nJsonPlugin from 'eslint-plugin-i18n-json';
|
|
4
|
+
import plugin from '../../../src';
|
|
5
|
+
import rule from '../../../src/rules/no-unsafe-template';
|
|
6
|
+
|
|
7
|
+
const lintJson = code => {
|
|
8
|
+
const linter = new Linter();
|
|
9
|
+
|
|
10
|
+
return linter.verify(
|
|
11
|
+
code,
|
|
12
|
+
{
|
|
13
|
+
files: ['**/*.json'],
|
|
14
|
+
plugins: {
|
|
15
|
+
'preact-i18n': plugin
|
|
16
|
+
},
|
|
17
|
+
processor: 'preact-i18n/translation-json',
|
|
18
|
+
rules: {
|
|
19
|
+
'preact-i18n/no-unsafe-template': 'error'
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
filename: 'intl/en.json'
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('no-unsafe-template', () => {
|
|
29
|
+
it('accepts safe translation templates', () => {
|
|
30
|
+
const messages = lintJson(
|
|
31
|
+
JSON.stringify({
|
|
32
|
+
plain: 'Hello {{name}}',
|
|
33
|
+
link: '<a href="{{url}}">{{label}}</a>',
|
|
34
|
+
image: '<img src="/images/logo.svg">'
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
assert.deepEqual(messages, []);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
[
|
|
42
|
+
['dynamic event handler', '<button onclick="{{handler}}">Open</button>', 'Event handler'],
|
|
43
|
+
['unquoted attribute', '<div title={{title}}>Title</div>', 'Unquoted attribute'],
|
|
44
|
+
['dynamic style', "<div style='color: {{color}}'>Text</div>", 'Dynamic CSS'],
|
|
45
|
+
['JavaScript URL', "<a href='javascript:alert(1)'>Open</a>", 'JavaScript protocol'],
|
|
46
|
+
['HTML data URI', "<iframe src='data:text/html,<h1>Unsafe</h1>'>", 'Data URI'],
|
|
47
|
+
['dynamic SVG attribute', '<svg fill="{{color}}"></svg>', 'SVG element'],
|
|
48
|
+
['unquoted URL', '<a href={{url}}>Open</a>', 'URL attribute']
|
|
49
|
+
].forEach(([name, template, message]) => {
|
|
50
|
+
it(`reports a ${name}`, () => {
|
|
51
|
+
const messages = lintJson(JSON.stringify({ nested: { template } }, null, 2));
|
|
52
|
+
|
|
53
|
+
assert.equal(messages.length, 1);
|
|
54
|
+
assert.match(messages[0].message, new RegExp(message));
|
|
55
|
+
assert.equal(messages[0].severity, 2);
|
|
56
|
+
assert.equal(messages[0].line, 3);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('checks decoded values containing escaped double quotes', () => {
|
|
61
|
+
const messages = lintJson(JSON.stringify({ template: '<div style="color: {{color}}">Text</div>' }));
|
|
62
|
+
|
|
63
|
+
assert.equal(messages.length, 1);
|
|
64
|
+
assert.match(messages[0].message, /Dynamic CSS/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('recognizes complete event names', () => {
|
|
68
|
+
const messages = lintJson(
|
|
69
|
+
JSON.stringify({ template: '<div onmouseover="show({{item}})">Text</div>' })
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
assert.equal(messages.length, 1);
|
|
73
|
+
assert.match(messages[0].message, /Event handler/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('supports the eslint-plugin-i18n-json processor', () => {
|
|
77
|
+
const linter = new Linter();
|
|
78
|
+
const messages = linter.verify(
|
|
79
|
+
JSON.stringify({ template: '<a href={{url}}>Open</a>' }),
|
|
80
|
+
{
|
|
81
|
+
files: ['**/*.json'],
|
|
82
|
+
plugins: {
|
|
83
|
+
'i18n-json': i18nJsonPlugin,
|
|
84
|
+
'preact-i18n': plugin
|
|
85
|
+
},
|
|
86
|
+
processor: {
|
|
87
|
+
meta: {
|
|
88
|
+
name: 'i18n-json'
|
|
89
|
+
},
|
|
90
|
+
...i18nJsonPlugin.processors['.json']
|
|
91
|
+
},
|
|
92
|
+
rules: {
|
|
93
|
+
'preact-i18n/no-unsafe-template': 'error'
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
filename: 'intl/en.json'
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
assert.equal(messages.length, 1);
|
|
102
|
+
assert.match(messages[0].message, /URL attribute/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('does not report overlapping generic and URL attribute errors twice', () => {
|
|
106
|
+
const issues = rule.findIssues('<a href={{url}}>Open</a>');
|
|
107
|
+
|
|
108
|
+
assert.equal(issues.length, 1);
|
|
109
|
+
assert.match(issues[0].description, /URL attribute/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('detects dynamic attributes on SVG descendants', () => {
|
|
113
|
+
const issues = rule.findIssues('<svg><path fill="{{color}}" /></svg>');
|
|
114
|
+
|
|
115
|
+
assert.equal(issues.length, 1);
|
|
116
|
+
assert.match(issues[0].description, /SVG element/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('detects dynamic attributes on SVG use elements', () => {
|
|
120
|
+
const issues = rule.findIssues('<svg><use href="{{url}}" /></svg>');
|
|
121
|
+
|
|
122
|
+
assert.equal(issues.length, 1);
|
|
123
|
+
assert.match(issues[0].description, /SVG element/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('reports both SVG and URL pattern when they overlap but are distinct patterns', () => {
|
|
127
|
+
const issues = rule.findIssues('<svg fill="{{color}}" href={{url}}></svg>');
|
|
128
|
+
|
|
129
|
+
assert.equal(issues.length, 2);
|
|
130
|
+
const descriptions = issues.map(i => i.description);
|
|
131
|
+
assert(descriptions.some(d => d.includes('SVG element')));
|
|
132
|
+
assert(descriptions.some(d => d.includes('URL attribute')));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('detects dynamic attributes on nested SVG elements with closing tag', () => {
|
|
136
|
+
const issues = rule.findIssues('<svg><circle cx="{{x}}" cy="{{y}}" /></svg>');
|
|
137
|
+
|
|
138
|
+
assert.equal(issues.length, 1);
|
|
139
|
+
assert.match(issues[0].description, /SVG element/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('detects multiple dynamic attributes in different SVG descendants', () => {
|
|
143
|
+
const issues = rule.findIssues('<svg><path d="{{path}}" /><text x="{{x}}">{{label}}</text></svg>');
|
|
144
|
+
|
|
145
|
+
assert.equal(issues.length, 1);
|
|
146
|
+
assert.match(issues[0].description, /SVG element/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('reports distinct high-risk patterns even when overlapping with broader patterns', () => {
|
|
150
|
+
// SVG pattern (MEDIUM) should not suppress the URL pattern (HIGH)
|
|
151
|
+
const issues = rule.findIssues('<svg src={{url}} onclick="{{handler}}">');
|
|
152
|
+
|
|
153
|
+
const descriptions = issues.map(i => i.description);
|
|
154
|
+
// Should report: URL attribute (HIGH) and Event handler (HIGH), and possibly SVG (MEDIUM)
|
|
155
|
+
assert(descriptions.some(d => d.includes('URL attribute')));
|
|
156
|
+
assert(descriptions.some(d => d.includes('Event handler')));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('detects interpolation with static prefix in SVG attribute', () => {
|
|
160
|
+
const issues = rule.findIssues('<svg fill="red {{color}}">')
|
|
161
|
+
.filter(i => i.description.includes('SVG'));
|
|
162
|
+
|
|
163
|
+
assert.equal(issues.length, 1);
|
|
164
|
+
assert.match(issues[0].description, /SVG element/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('detects interpolation with static suffix in SVG attribute', () => {
|
|
168
|
+
const issues = rule.findIssues('<svg><use href="#{{id}}" /></svg>')
|
|
169
|
+
.filter(i => i.description.includes('SVG'));
|
|
170
|
+
|
|
171
|
+
assert.equal(issues.length, 1);
|
|
172
|
+
assert.match(issues[0].description, /SVG element/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('detects interpolation with both prefix and suffix in SVG attribute', () => {
|
|
176
|
+
const issues = rule.findIssues('<svg><path d="M 10 {{y}} L 20 30" /></svg>')
|
|
177
|
+
.filter(i => i.description.includes('SVG'));
|
|
178
|
+
|
|
179
|
+
assert.equal(issues.length, 1);
|
|
180
|
+
assert.match(issues[0].description, /SVG element/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('detects interpolation in self-closing SVG tag with attribute prefix', () => {
|
|
184
|
+
const issues = rule.findIssues('<svg viewBox="0 {{scale}} 100" />')
|
|
185
|
+
.filter(i => i.description.includes('SVG'));
|
|
186
|
+
|
|
187
|
+
assert.equal(issues.length, 1);
|
|
188
|
+
assert.match(issues[0].description, /SVG element/);
|
|
189
|
+
});
|
|
190
|
+
});
|