Skip to content

Commit c59cfa9

Browse files
authored
breaking: HTML message warning (#29)
* breaking: HTML message warning * update docs
1 parent 44b8d43 commit c59cfa9

File tree

11 files changed

+170
-23
lines changed

11 files changed

+170
-23
lines changed

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ The examples are offered that use the following two API styles:
8686
- `VueI18n.prototype.getChoiceIndex`
8787
- -> Legacy API style: `pluralizationRules` option of `createI18n` factory function (like `new VueI18n(...)`)
8888
- -> Compsable API style: `pluralRules` option of `createI18nComposer` factory function
89+
- `warnHtmlInMessage` option:
90+
- Composable API: `warnHtmlMessage` boolean option, default `true`.
91+
- For development mode, warning is default.
92+
- For production mode, HTML message detect is not check due to performance.
8993
- `VueI18n.version` -> `import { VERSION } from 'vue-i18n'`
9094
- `VueI18n.availabilities` -> `import { availabilities } from 'vue-i18n'`
9195
- See the details [here](https://github.com/intlify/vue-i18n-next/blob/master/docs/vue-i18n.md)
@@ -137,15 +141,15 @@ yarn add vue-i18n@next
137141
- Intlify message format compiler
138142
- [x] vue-i18n message format
139143
- [ ] sourcemap
140-
- [ ] HTML format handling
144+
- [x] HTML format handling
141145
- [x] error handling
142146
- [ ] more unit (fuzzing) tests
143147
- [ ] performance tests (benchmark)
144148
- Intlify core runtime
145149
- [x] translate function
146150
- [x] datetime function
147151
- [x] number function
148-
- [ ] warnHtmlInMessage
152+
- [x] warnHtmlMessage
149153
- [x] improve translate `args` typing
150154
- [ ] improve locale messages typing: `LocaleMessages` / `LocaleMessage` / `LocaleMessageDictiory`
151155
- [x] postTranslation context option
@@ -163,6 +167,7 @@ yarn add vue-i18n@next
163167
- [x] fallbackFormat
164168
- [x] dateTimeFormats
165169
- [x] numberFormats
170+
- [x] warnHtmlMessage
166171
- methods
167172
- [x] t
168173
- [x] getLocaleMessages
@@ -195,7 +200,7 @@ yarn add vue-i18n@next
195200
- [x] silentFallbackWarn
196201
- [x] formatFallbackMessages
197202
- [ ] preserveDirectiveContent
198-
- [ ] warnHtmlInMessage
203+
- [x] warnHtmlInMessage
199204
- [x] postTranslation
200205
- [x] t
201206
- [x] tc

src/composer.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export type ComposerOptions = {
9696
fallbackRoot?: boolean
9797
fallbackFormat?: boolean
9898
postTranslation?: PostTranslationHandler
99+
warnHtmlMessage?: boolean
99100
__i18n?: CustomBlocks // for custom blocks, and internal
100101
__root?: Composer // for internal
101102
}
@@ -119,6 +120,7 @@ export type Composer = {
119120
fallbackWarn: boolean | RegExp
120121
fallbackRoot: boolean
121122
fallbackFormat: boolean
123+
warnHtmlMessage: boolean
122124
__id: number // for internal
123125

124126
/**
@@ -302,6 +304,10 @@ export function createComposer(options: ComposerOptions = {}): Composer {
302304
? options.postTranslation
303305
: null
304306

307+
let _warnHtmlMessage = isBoolean(options.warnHtmlMessage)
308+
? options.warnHtmlMessage
309+
: true
310+
305311
// custom linked modifiers
306312
// prettier-ignore
307313
const _modifiers = __root
@@ -330,6 +336,7 @@ export function createComposer(options: ComposerOptions = {}): Composer {
330336
fallbackFormat: _fallbackFormat,
331337
unresolving: true,
332338
postTranslation: _postTranslation === null ? undefined : _postTranslation,
339+
warnHtmlMessage: _warnHtmlMessage,
333340
_datetimeFormatters: isPlainObject(_context)
334341
? _context._datetimeFormatters
335342
: undefined,
@@ -645,6 +652,13 @@ export function createComposer(options: ComposerOptions = {}): Composer {
645652
_fallbackFormat = val
646653
_context.fallbackFormat = _fallbackFormat
647654
},
655+
get warnHtmlMessage(): boolean {
656+
return _warnHtmlMessage
657+
},
658+
set warnHtmlMessage(val: boolean) {
659+
_warnHtmlMessage = val
660+
_context.warnHtmlMessage = val
661+
},
648662
__id: composerID,
649663

650664
/**

src/legacy.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,11 @@ export type VueI18n = {
110110
silentFallbackWarn: boolean | RegExp
111111
formatFallbackMessages: boolean
112112
sync: boolean
113+
warnHtmlInMessage: WarnHtmlInMessageLevel
113114
__id: number
114115
__composer: Composer
115116
/*
116117
preserveDirectiveContent: boolean
117-
warnHtmlInMessage: WarnHtmlInMessageLevel
118118
*/
119119

120120
/**
@@ -192,6 +192,9 @@ function convertComposerOptions(options: VueI18nOptions): ComposerOptions {
192192
const postTranslation = isFunction(options.postTranslation)
193193
? options.postTranslation
194194
: undefined
195+
const warnHtmlMessage = isString(options.warnHtmlInMessage)
196+
? options.warnHtmlInMessage !== 'off'
197+
: true
195198

196199
if (__DEV__ && options.formatter) {
197200
warn(`not supportted 'formatter' option`)
@@ -225,6 +228,7 @@ function convertComposerOptions(options: VueI18nOptions): ComposerOptions {
225228
fallbackFormat,
226229
pluralRules: pluralizationRules,
227230
postTranslation,
231+
warnHtmlMessage,
228232
__i18n,
229233
__root
230234
}
@@ -345,6 +349,14 @@ export function createVueI18n(options: VueI18nOptions = {}): VueI18n {
345349
options.sync = val
346350
},
347351

352+
// warnInHtmlMessage
353+
get warnHtmlInMessage(): WarnHtmlInMessageLevel {
354+
return composer.warnHtmlMessage ? 'warn' : 'off'
355+
},
356+
set warnHtmlInMessage(val: WarnHtmlInMessageLevel) {
357+
composer.warnHtmlMessage = val !== 'off'
358+
},
359+
348360
// for internal
349361
__id: composer.__id,
350362
__composer: composer,

src/message/compiler.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createParser, ResourceNode } from './parser'
33
import { transform } from './transformer'
44
import { generate } from './generator'
55
import { CompileError, defaultOnError } from './errors'
6+
import { warn, isBoolean } from '../utils'
67

78
export type CompileResult = {
89
code: string
@@ -18,22 +19,38 @@ export type Compiler = Readonly<{
1819

1920
export type MessageFunction = (ctx: unknown) => unknown
2021

22+
// TODO: This code should be removed with using rollup (`/*#__PURE__*/`)
23+
const RE_HTML_TAG = /<\/?[\w\s="/.':;#-\/]+>/
24+
function checkHtmlMessage(source: string, options: CompileOptions): void {
25+
const warnHtmlMessage = isBoolean(options.warnHtmlMessage)
26+
? options.warnHtmlMessage
27+
: true
28+
if (warnHtmlMessage && RE_HTML_TAG.test(source)) {
29+
warn(
30+
`Detected HTML in '${source}' message. Recommend not using HTML messages to avoid XSS.`
31+
)
32+
}
33+
}
34+
2135
const defaultOnCacheKey = (source: string): string => source
2236
const compileCache: Record<string, MessageFunction> = Object.create(null)
2337

2438
export function compile(
2539
source: string,
2640
options: CompileOptions = {}
2741
): MessageFunction {
28-
const onCacheKey = options.onCacheKey || defaultOnCacheKey
42+
// check HTML message
43+
__DEV__ && checkHtmlMessage(source, options)
2944

3045
// check caches
46+
const onCacheKey = options.onCacheKey || defaultOnCacheKey
3147
const key = onCacheKey(source)
3248
const cached = compileCache[key]
3349
if (cached) {
3450
return cached
3551
}
3652

53+
// compile error detecting
3754
let occured = false
3855
const onError = options.onError || defaultOnError
3956
options.onError = (err: CompileError): void => {

src/message/options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type CodeGenOptions = {
3434
}
3535

3636
export type CompileOptions = {
37+
warnHtmlMessage?: boolean
3738
onCacheKey?: CompileCacheKeyHandler
3839
} & TransformOptions &
3940
CodeGenOptions &

src/runtime/context.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export type RuntimeOptions = {
6161
unresolving?: boolean
6262
postTranslation?: PostTranslationHandler
6363
processor?: MessageProcessor
64+
warnHtmlMessage?: boolean
6465
_datetimeFormatters?: Map<string, Intl.DateTimeFormat>
6566
_numberFormatters?: Map<string, Intl.NumberFormat>
6667
}
@@ -80,6 +81,7 @@ export type RuntimeContext = {
8081
unresolving: boolean
8182
postTranslation: PostTranslationHandler | null
8283
processor: MessageProcessor | null
84+
warnHtmlMessage: boolean
8385
_datetimeFormatters: Map<string, Intl.DateTimeFormat>
8486
_numberFormatters: Map<string, Intl.NumberFormat>
8587
_fallbackLocaleStack?: Locale[]
@@ -141,6 +143,9 @@ export function createRuntimeContext(
141143
? options.postTranslation
142144
: null
143145
const processor = isPlainObject(options.processor) ? options.processor : null
146+
const warnHtmlMessage = isBoolean(options.warnHtmlMessage)
147+
? options.warnHtmlMessage
148+
: true
144149
const _datetimeFormatters = isObject(options._datetimeFormatters)
145150
? options._datetimeFormatters
146151
: new Map<string, Intl.DateTimeFormat>()
@@ -163,6 +168,7 @@ export function createRuntimeContext(
163168
unresolving,
164169
postTranslation,
165170
processor,
171+
warnHtmlMessage,
166172
_datetimeFormatters,
167173
_numberFormatters
168174
}

src/runtime/translate.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,8 @@ export function translate(
165165
fallbackFormat,
166166
postTranslation,
167167
unresolving,
168-
fallbackLocale
168+
fallbackLocale,
169+
warnHtmlMessage
169170
} = context
170171
const [key, options] = parseTranslateArgs(...args)
171172

@@ -240,7 +241,13 @@ export function translate(
240241
? format
241242
: compile(
242243
format,
243-
getCompileOptions(targetLocale, cacheBaseKey, format, errorDetector)
244+
getCompileOptions(
245+
targetLocale,
246+
cacheBaseKey,
247+
format,
248+
warnHtmlMessage,
249+
errorDetector
250+
)
244251
)
245252

246253
// if occured compile error, return the message format
@@ -298,9 +305,11 @@ function getCompileOptions(
298305
locale: Locale,
299306
key: string,
300307
source: string,
308+
warnHtmlMessage: boolean,
301309
errorDetector?: Function
302310
): CompileOptions {
303311
return {
312+
warnHtmlMessage,
304313
onError: (err: CompileError): void => {
305314
errorDetector && errorDetector(err)
306315
if (__DEV__) {
@@ -338,7 +347,13 @@ function getMessageContextOptions(
338347
}
339348
const msg = compile(
340349
val,
341-
getCompileOptions(locale, key, val, errorDetector)
350+
getCompileOptions(
351+
locale,
352+
key,
353+
val,
354+
context.warnHtmlMessage,
355+
errorDetector
356+
)
342357
)
343358
return !occured ? msg : NOOP_MESSAGE_FUNCTION
344359
} else if (isMessageFunction(val)) {

test/legacy.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,3 +415,26 @@ test('getChoiceIndex', () => {
415415
`not supportted 'getChoiceIndex' method.`
416416
)
417417
})
418+
419+
test('warnHtmlInMessage', () => {
420+
const mockWarn = warn as jest.MockedFunction<typeof warn>
421+
mockWarn.mockImplementation(() => {}) // eslint-disable-line @typescript-eslint/no-empty-function
422+
423+
const i18n = createVueI18n({
424+
locale: 'en',
425+
messages: {
426+
en: {
427+
hello: '<p>hello</p>'
428+
}
429+
}
430+
})
431+
432+
expect(i18n.t('hello')).toEqual('<p>hello</p>')
433+
434+
i18n.warnHtmlInMessage = 'off'
435+
expect(i18n.t('hello')).toEqual('<p>hello</p>')
436+
437+
i18n.warnHtmlInMessage = 'error'
438+
expect(i18n.t('hello')).toEqual('<p>hello</p>')
439+
expect(mockWarn).toHaveBeenCalledTimes(2)
440+
})

test/message/__snapshots__/compiler.test.ts.snap

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,19 @@ Object {
4949
"message": "Plural must have messages",
5050
}
5151
`;
52+
53+
exports[`warnHtmlMessage default: code 1`] = `
54+
"function __msg__ (ctx) {
55+
return ctx.normalize([
56+
\\"<p>hello</p>\\"
57+
])
58+
}"
59+
`;
60+
61+
exports[`warnHtmlMessage false: code 1`] = `
62+
"function __msg__ (ctx) {
63+
return ctx.normalize([
64+
\\"<p>hello</p>\\"
65+
])
66+
}"
67+
`;

test/message/compiler.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
/* eslint-disable @typescript-eslint/no-empty-function */
2+
3+
// utils
4+
jest.mock('../../src/utils', () => ({
5+
...jest.requireActual('../../src/utils'),
6+
warn: jest.fn()
7+
}))
8+
import { warn } from '../../src/utils'
9+
110
import { compile } from '../../src/message/compiler'
211

312
/* eslint-disable no-irregular-whitespace */
@@ -7,6 +16,29 @@ test(`@.caml:{'no apples'} | {0} apple | {n} apples`, () => {
716
})
817
/* eslint-enable no-irregular-whitespace */
918

19+
describe('warnHtmlMessage', () => {
20+
test('default', () => {
21+
const mockWarn = warn as jest.MockedFunction<typeof warn>
22+
mockWarn.mockImplementation(() => {})
23+
24+
const code = compile('<p>hello</p>')
25+
expect(code.toString()).toMatchSnapshot('code')
26+
expect(mockWarn).toHaveBeenCalled()
27+
expect(mockWarn.mock.calls[0][0]).toEqual(
28+
`Detected HTML in '<p>hello</p>' message. Recommend not using HTML messages to avoid XSS.`
29+
)
30+
})
31+
32+
test('false', () => {
33+
const mockWarn = warn as jest.MockedFunction<typeof warn>
34+
mockWarn.mockImplementation(() => {})
35+
36+
const code = compile('<p>hello</p>', { warnHtmlMessage: false })
37+
expect(code.toString()).toMatchSnapshot('code')
38+
expect(mockWarn).not.toHaveBeenCalled()
39+
})
40+
})
41+
1042
describe('edge cases', () => {
1143
test(` | | | `, () => {
1244
const code = compile(` | | | `, {
@@ -17,3 +49,5 @@ describe('edge cases', () => {
1749
expect(code.toString()).toMatchSnapshot('code')
1850
})
1951
})
52+
53+
/* eslint-enable @typescript-eslint/no-empty-function */

0 commit comments

Comments
 (0)