Skip to content

Commit e75546f

Browse files
committed
⭐ new(rule): add no-html-messages rule
1 parent 17a1aed commit e75546f

File tree

12 files changed

+375
-1
lines changed

12 files changed

+375
-1
lines changed

Diff for: README.md

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Details changes for each release are documented in the [CHANGELOG.md](https://gi
2525
- [x] no-dynamic-keys
2626
- [x] no-unused-keys
2727
- [x] no-v-html
28+
- [ ] no-html-message
2829
- [ ] no-raw-text
2930
- [ ] valid-message-syntax
3031
- [ ] keys-order

Diff for: docs/rules/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
| Rule ID | Description | |
99
|:--------|:------------|:---|
10+
| [vue-i18n/<wbr>no-html-messages](./no-html-messages.html) | disallow use HTML localization messages | :star: |
1011
| [vue-i18n/<wbr>no-missing-keys](./no-missing-keys.html) | disallow missing locale message key at localization methods | :star: |
1112
| [vue-i18n/<wbr>no-v-html](./no-v-html.html) | disallow use of localization methods on v-html to prevent XSS attack | :star: |
1213

Diff for: docs/rules/no-html-messages.md

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# vue-i18n/no-html-messages
2+
3+
> disallow use HTML localization messages
4+
5+
- :star: The `"extends": "plugin:vue-i18n/recommended"` property in a configuration file enables this rule.
6+
7+
This rule reports in order to reduce the risk of injecting potentially unsafe localization message into the browser leading to supply-chain attack or XSS attack.
8+
9+
## :book: Rule Details
10+
11+
This rule is aimed at eliminating HTML localization messages.
12+
13+
:-1: Examples of **incorrect** code for this rule:
14+
15+
locale messages:
16+
```js
17+
// ✗ BAD
18+
{
19+
"hello": "Hello! DIO!",
20+
"hi": "Hi! <span>DIO!</span>",
21+
"contenst": {
22+
"banner": "banner: <iframe src=\"https://banner.domain.com\" frameBorder=\"0\" style=\"z-index:100001;position:fixed;bottom:0;right:0\"/>",
23+
"modal": "modal: <span onmouseover=\"alert(document.cookie);\">modal content</span>"
24+
}
25+
}
26+
```
27+
28+
In localization codes of application:
29+
30+
```vue
31+
<template>
32+
<div class="app">
33+
<p>{{ $t('hello') }}</p>
34+
<!-- supply-chain attack -->
35+
<div v-html="$t('contents.banner')"></div>
36+
<!-- XSS attack -->
37+
<div v-html="$t('contents.modal')"></div>
38+
</div>
39+
</template>
40+
```
41+
42+
```js
43+
const i18n = new VueI18n({
44+
locale: 'en',
45+
messages: {
46+
en: require('./locales/en.json')
47+
}
48+
})
49+
50+
new Vue({
51+
i18n,
52+
// ...
53+
}).$mount('#app')
54+
```
55+
56+
:+1: Examples of **correct** code for this rule:
57+
58+
locale messages:
59+
// ✓ GOOD
60+
```js
61+
{
62+
"hello": "Hello! DIO!",
63+
"hi": "Hi! DIO!",
64+
"contents": {
65+
"banner": "banner: {0}",
66+
"modal": "modal: {0}"
67+
}
68+
}
69+
```
70+
71+
In localization codes of application:
72+
73+
```vue
74+
<template>
75+
<div class="app">
76+
<p>{{ $t('hello') }}</p>
77+
<i18n path="contents.banner">
78+
<Banner :url="bannerURL"/>
79+
</i18n>
80+
<i18n path="contents.modal">
81+
<Modal :url="modalDataURL"/>
82+
</i18n>
83+
</div>
84+
</template>
85+
```
86+
87+
```js
88+
// import some components used in i18n component
89+
import Banner from './path/to/components/Banner.vue'
90+
import Modal from './path/to/components/Modal.vue'
91+
92+
// register imprted components (in this example case, Vue.component)
93+
Vue.component('Banner', Banner)
94+
Vue.component('Modal', Modal)
95+
96+
const i18n = new VueI18n({
97+
locale: 'en',
98+
messages: {
99+
en: require('./locales/en.json')
100+
}
101+
})
102+
103+
new Vue({
104+
i18n,
105+
data () {
106+
return {
107+
bannerURL: 'https://banner.domain.com',
108+
modalDataURL: 'https://fetch.domain.com'
109+
}
110+
}
111+
// ...
112+
}).$mount('#app')
113+
```

Diff for: lib/configs/recommended.js

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ module.exports = {
1616
},
1717
plugins: ['vue-i18n'],
1818
rules: {
19+
'vue-i18n/no-html-messages': 'error',
1920
'vue-i18n/no-missing-keys': 'error',
2021
'vue-i18n/no-v-html': 'error'
2122
}

Diff for: lib/processors/json.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ module.exports = {
1818
postprocess ([errors], filename) {
1919
delete localeMessageFiles[filename]
2020
return [...errors.filter(
21-
error => !error.ruleId || error.ruleId === 'vue-i18n/no-unused-keys'
21+
error => !error.ruleId ||
22+
(error.ruleId === 'vue-i18n/no-unused-keys' || error.ruleId === 'vue-i18n/no-html-messages')
2223
)]
2324
},
2425

Diff for: lib/rules.js

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
module.exports = {
55
'no-dynamic-keys': require('./rules/no-dynamic-keys'),
6+
'no-html-messages': require('./rules/no-html-messages'),
67
'no-missing-keys': require('./rules/no-missing-keys'),
78
'no-unused-keys': require('./rules/no-unused-keys'),
89
'no-v-html': require('./rules/no-v-html')

Diff for: lib/rules/no-html-messages.js

+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* @author kazuya kawaguchi (a.k.a. kazupon)
3+
*/
4+
'use strict'
5+
6+
const { extname } = require('path')
7+
const jsonAstParse = require('json-to-ast')
8+
const parse5 = require('parse5')
9+
const { UNEXPETECD_ERROR_LOCATION, loadLocaleMessages } = require('../utils/index')
10+
const debug = require('debug')('eslint-plugin-vue-i18n:no-html-messages')
11+
12+
let localeMessages = null // used locale messages
13+
let localeDir = null
14+
15+
function findExistLocaleMessage (fullpath, localeMessages) {
16+
return localeMessages.find(message => message.fullpath === fullpath)
17+
}
18+
19+
function extractJsonInfo (context, node) {
20+
try {
21+
const [str, filename] = node.comments
22+
return [
23+
Buffer.from(str.value, 'base64').toString(),
24+
Buffer.from(filename.value, 'base64').toString()
25+
]
26+
} catch (e) {
27+
context.report({
28+
loc: UNEXPETECD_ERROR_LOCATION,
29+
message: e.message
30+
})
31+
return []
32+
}
33+
}
34+
35+
function generateJsonAst (context, json, filename) {
36+
let ast = null
37+
38+
try {
39+
ast = jsonAstParse(json, { loc: true, source: filename })
40+
} catch (e) {
41+
const { message, line, column } = e
42+
context.report({
43+
message,
44+
loc: { line, column }
45+
})
46+
}
47+
48+
return ast
49+
}
50+
51+
function traverseNode (node, fn) {
52+
if (node.type === 'Object' && node.children.length > 0) {
53+
node.children.forEach(child => {
54+
if (child.type === 'Property') {
55+
const keyNode = child.key
56+
const valueNode = child.value
57+
if (keyNode.type === 'Identifier' && valueNode.type === 'Object') {
58+
return traverseNode(valueNode, fn)
59+
} else {
60+
return fn(valueNode)
61+
}
62+
}
63+
})
64+
}
65+
}
66+
67+
function findHTMLNode (node) {
68+
return node.childNodes.find(child => {
69+
if (child.nodeName !== '#text' && child.tagName) {
70+
return true
71+
}
72+
})
73+
}
74+
75+
function create (context) {
76+
const filename = context.getFilename()
77+
if (extname(filename) !== '.json') {
78+
debug(`ignore ${filename} in no-html-messages`)
79+
return {}
80+
}
81+
82+
const { settings } = context
83+
if (!settings['vue-i18n'] || !settings['vue-i18n'].localeDir) {
84+
context.report({
85+
loc: UNEXPETECD_ERROR_LOCATION,
86+
message: `You need to 'localeDir' at 'settings. See the 'eslint-plugin-vue-i18n documentation`
87+
})
88+
return {}
89+
}
90+
91+
if (localeDir !== settings['vue-i18n'].localeDir) {
92+
debug(`change localeDir: ${localeDir} -> ${settings['vue-i18n'].localeDir}`)
93+
localeDir = settings['vue-i18n'].localeDir
94+
localeMessages = loadLocaleMessages(localeDir)
95+
} else {
96+
localeMessages = localeMessages || loadLocaleMessages(settings['vue-i18n'].localeDir)
97+
}
98+
99+
const targetLocaleMessage = findExistLocaleMessage(filename, localeMessages)
100+
if (!targetLocaleMessage) {
101+
debug(`ignore ${filename} in no-html-messages`)
102+
return {}
103+
}
104+
105+
return {
106+
Program (node) {
107+
const [jsonString, jsonFilename] = extractJsonInfo(context, node)
108+
if (!jsonString || !jsonFilename) { return }
109+
110+
const ast = generateJsonAst(context, jsonString, jsonFilename)
111+
if (!ast) { return }
112+
113+
traverseNode(ast, messageNode => {
114+
const htmlNode = parse5.parseFragment(messageNode.value, { sourceCodeLocationInfo: true })
115+
const foundNode = findHTMLNode(htmlNode)
116+
if (!foundNode) { return }
117+
context.report({
118+
message: `used HTML localization message in '${targetLocaleMessage.path}'`,
119+
loc: {
120+
line: messageNode.loc.start.line,
121+
column: messageNode.loc.start.column + foundNode.sourceCodeLocation.startOffset
122+
}
123+
})
124+
})
125+
}
126+
}
127+
}
128+
129+
module.exports = {
130+
meta: {
131+
type: 'problem',
132+
docs: {
133+
description: 'disallow use HTML localization messages',
134+
category: 'Recommended',
135+
recommended: true
136+
},
137+
fixable: null,
138+
schema: []
139+
},
140+
create
141+
}

Diff for: package.json

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"json-to-ast": "^2.1.0",
1717
"jsondiffpatch": "^0.3.11",
1818
"lodash": "^4.17.11",
19+
"parse5": "^5.1.0",
1920
"vue-eslint-parser": "^6.0.3"
2021
},
2122
"devDependencies": {

Diff for: tests/fixtures/no-html-messages/invalid/en.json

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"hello": "Hello! DIO!",
3+
"hi": "Hi! <span>DIO!</span>",
4+
"contents": {
5+
"banner": "banner: <iframe src=\"https://banner.domain.com\" frameBorder=\"0\" style=\"z-index:100001;position:fixed;bottom:0;right:0\"/>",
6+
"modal": "modal: <span onmouseover=\"alert(document.cookie);\">modal content</span>"
7+
}
8+
}

Diff for: tests/fixtures/no-html-messages/valid/en.json

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"hello": "Hello! DIO!",
3+
"hi": "Hi! DIO!",
4+
"contents": {
5+
"banner": "banner: {0}",
6+
"modal": "modal: {0}"
7+
}
8+
}

0 commit comments

Comments
 (0)