Skip to content

Commit

Permalink
⭐ new(rule): add no-html-messages rule
Browse files Browse the repository at this point in the history
  • Loading branch information
kazupon committed Mar 22, 2019
1 parent 17a1aed commit e75546f
Show file tree
Hide file tree
Showing 12 changed files with 375 additions and 1 deletion.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Details changes for each release are documented in the [CHANGELOG.md](https://gi
- [x] no-dynamic-keys
- [x] no-unused-keys
- [x] no-v-html
- [ ] no-html-message
- [ ] no-raw-text
- [ ] valid-message-syntax
- [ ] keys-order
Expand Down
1 change: 1 addition & 0 deletions docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

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

Expand Down
113 changes: 113 additions & 0 deletions docs/rules/no-html-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# vue-i18n/no-html-messages

> disallow use HTML localization messages
- :star: The `"extends": "plugin:vue-i18n/recommended"` property in a configuration file enables this rule.

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.

## :book: Rule Details

This rule is aimed at eliminating HTML localization messages.

:-1: Examples of **incorrect** code for this rule:

locale messages:
```js
// ✗ BAD
{
"hello": "Hello! DIO!",
"hi": "Hi! <span>DIO!</span>",
"contenst": {
"banner": "banner: <iframe src=\"https://banner.domain.com\" frameBorder=\"0\" style=\"z-index:100001;position:fixed;bottom:0;right:0\"/>",
"modal": "modal: <span onmouseover=\"alert(document.cookie);\">modal content</span>"
}
}
```

In localization codes of application:

```vue
<template>
<div class="app">
<p>{{ $t('hello') }}</p>
<!-- supply-chain attack -->
<div v-html="$t('contents.banner')"></div>
<!-- XSS attack -->
<div v-html="$t('contents.modal')"></div>
</div>
</template>
```

```js
const i18n = new VueI18n({
locale: 'en',
messages: {
en: require('./locales/en.json')
}
})

new Vue({
i18n,
// ...
}).$mount('#app')
```

:+1: Examples of **correct** code for this rule:

locale messages:
// ✓ GOOD
```js
{
"hello": "Hello! DIO!",
"hi": "Hi! DIO!",
"contents": {
"banner": "banner: {0}",
"modal": "modal: {0}"
}
}
```

In localization codes of application:

```vue
<template>
<div class="app">
<p>{{ $t('hello') }}</p>
<i18n path="contents.banner">
<Banner :url="bannerURL"/>
</i18n>
<i18n path="contents.modal">
<Modal :url="modalDataURL"/>
</i18n>
</div>
</template>
```

```js
// import some components used in i18n component
import Banner from './path/to/components/Banner.vue'
import Modal from './path/to/components/Modal.vue'

// register imprted components (in this example case, Vue.component)
Vue.component('Banner', Banner)
Vue.component('Modal', Modal)

const i18n = new VueI18n({
locale: 'en',
messages: {
en: require('./locales/en.json')
}
})

new Vue({
i18n,
data () {
return {
bannerURL: 'https://banner.domain.com',
modalDataURL: 'https://fetch.domain.com'
}
}
// ...
}).$mount('#app')
```
1 change: 1 addition & 0 deletions lib/configs/recommended.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module.exports = {
},
plugins: ['vue-i18n'],
rules: {
'vue-i18n/no-html-messages': 'error',
'vue-i18n/no-missing-keys': 'error',
'vue-i18n/no-v-html': 'error'
}
Expand Down
3 changes: 2 additions & 1 deletion lib/processors/json.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ module.exports = {
postprocess ([errors], filename) {
delete localeMessageFiles[filename]
return [...errors.filter(
error => !error.ruleId || error.ruleId === 'vue-i18n/no-unused-keys'
error => !error.ruleId ||
(error.ruleId === 'vue-i18n/no-unused-keys' || error.ruleId === 'vue-i18n/no-html-messages')
)]
},

Expand Down
1 change: 1 addition & 0 deletions lib/rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

module.exports = {
'no-dynamic-keys': require('./rules/no-dynamic-keys'),
'no-html-messages': require('./rules/no-html-messages'),
'no-missing-keys': require('./rules/no-missing-keys'),
'no-unused-keys': require('./rules/no-unused-keys'),
'no-v-html': require('./rules/no-v-html')
Expand Down
141 changes: 141 additions & 0 deletions lib/rules/no-html-messages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* @author kazuya kawaguchi (a.k.a. kazupon)
*/
'use strict'

const { extname } = require('path')
const jsonAstParse = require('json-to-ast')
const parse5 = require('parse5')
const { UNEXPETECD_ERROR_LOCATION, loadLocaleMessages } = require('../utils/index')
const debug = require('debug')('eslint-plugin-vue-i18n:no-html-messages')

let localeMessages = null // used locale messages
let localeDir = null

function findExistLocaleMessage (fullpath, localeMessages) {
return localeMessages.find(message => message.fullpath === fullpath)
}

function extractJsonInfo (context, node) {
try {
const [str, filename] = node.comments
return [
Buffer.from(str.value, 'base64').toString(),
Buffer.from(filename.value, 'base64').toString()
]
} catch (e) {
context.report({
loc: UNEXPETECD_ERROR_LOCATION,
message: e.message
})
return []
}
}

function generateJsonAst (context, json, filename) {
let ast = null

try {
ast = jsonAstParse(json, { loc: true, source: filename })
} catch (e) {
const { message, line, column } = e
context.report({
message,
loc: { line, column }
})
}

return ast
}

function traverseNode (node, fn) {
if (node.type === 'Object' && node.children.length > 0) {
node.children.forEach(child => {
if (child.type === 'Property') {
const keyNode = child.key
const valueNode = child.value
if (keyNode.type === 'Identifier' && valueNode.type === 'Object') {
return traverseNode(valueNode, fn)
} else {
return fn(valueNode)
}
}
})
}
}

function findHTMLNode (node) {
return node.childNodes.find(child => {
if (child.nodeName !== '#text' && child.tagName) {
return true
}
})
}

function create (context) {
const filename = context.getFilename()
if (extname(filename) !== '.json') {
debug(`ignore ${filename} in no-html-messages`)
return {}
}

const { settings } = context
if (!settings['vue-i18n'] || !settings['vue-i18n'].localeDir) {
context.report({
loc: UNEXPETECD_ERROR_LOCATION,
message: `You need to 'localeDir' at 'settings. See the 'eslint-plugin-vue-i18n documentation`
})
return {}
}

if (localeDir !== settings['vue-i18n'].localeDir) {
debug(`change localeDir: ${localeDir} -> ${settings['vue-i18n'].localeDir}`)
localeDir = settings['vue-i18n'].localeDir
localeMessages = loadLocaleMessages(localeDir)
} else {
localeMessages = localeMessages || loadLocaleMessages(settings['vue-i18n'].localeDir)
}

const targetLocaleMessage = findExistLocaleMessage(filename, localeMessages)
if (!targetLocaleMessage) {
debug(`ignore ${filename} in no-html-messages`)
return {}
}

return {
Program (node) {
const [jsonString, jsonFilename] = extractJsonInfo(context, node)
if (!jsonString || !jsonFilename) { return }

const ast = generateJsonAst(context, jsonString, jsonFilename)
if (!ast) { return }

traverseNode(ast, messageNode => {
const htmlNode = parse5.parseFragment(messageNode.value, { sourceCodeLocationInfo: true })
const foundNode = findHTMLNode(htmlNode)
if (!foundNode) { return }
context.report({
message: `used HTML localization message in '${targetLocaleMessage.path}'`,
loc: {
line: messageNode.loc.start.line,
column: messageNode.loc.start.column + foundNode.sourceCodeLocation.startOffset
}
})
})
}
}
}

module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow use HTML localization messages',
category: 'Recommended',
recommended: true
},
fixable: null,
schema: []
},
create
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"json-to-ast": "^2.1.0",
"jsondiffpatch": "^0.3.11",
"lodash": "^4.17.11",
"parse5": "^5.1.0",
"vue-eslint-parser": "^6.0.3"
},
"devDependencies": {
Expand Down
8 changes: 8 additions & 0 deletions tests/fixtures/no-html-messages/invalid/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"hello": "Hello! DIO!",
"hi": "Hi! <span>DIO!</span>",
"contents": {
"banner": "banner: <iframe src=\"https://banner.domain.com\" frameBorder=\"0\" style=\"z-index:100001;position:fixed;bottom:0;right:0\"/>",
"modal": "modal: <span onmouseover=\"alert(document.cookie);\">modal content</span>"
}
}
8 changes: 8 additions & 0 deletions tests/fixtures/no-html-messages/valid/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"hello": "Hello! DIO!",
"hi": "Hi! DIO!",
"contents": {
"banner": "banner: {0}",
"modal": "modal: {0}"
}
}
Loading

0 comments on commit e75546f

Please sign in to comment.