diff --git a/packages/unplugin-vue-i18n/README.md b/packages/unplugin-vue-i18n/README.md index 03f878b..98974ff 100644 --- a/packages/unplugin-vue-i18n/README.md +++ b/packages/unplugin-vue-i18n/README.md @@ -300,6 +300,31 @@ This plugin will automatically select and bundle `petite-vue-i18n` build accordi > ⚠️ NOTE: If you use the `js` and `ts` resources formats, set the paths, so your application code is not targeted. We recommend that resources be isolated from the application code. + +### `strictMessage` + +- **Type:** `boolean` +- **Default:** `true` + + Strictly checks that the locale message does not contain html tags. + + If html tags are included, an error is thrown. + + If you would not the error to be thrown, you can work around it by setting it to `false`, but **it means that the locale message might cause security problems with XSS**. + + In that case, we recommend setting the `escapeHtml` option to `true`. + + +### `escapeHtml` + +- **Type:** `boolean` +- **Default:** `false` + + Whether to escape html tags if they are included in the locale message. + + If `strictMessage` is disabled by `false`, we recommend this option be enabled. + + ### `allowDynamic` - **Type:** `boolean` diff --git a/packages/unplugin-vue-i18n/package.json b/packages/unplugin-vue-i18n/package.json index 52df4d7..0291d75 100644 --- a/packages/unplugin-vue-i18n/package.json +++ b/packages/unplugin-vue-i18n/package.json @@ -26,7 +26,7 @@ } }, "dependencies": { - "@intlify/bundle-utils": "^5.3.1", + "@intlify/bundle-utils": "^5.4.0", "@intlify/shared": "9.3.0-beta.17", "@rollup/pluginutils": "^5.0.2", "@vue/compiler-sfc": "^3.2.47", diff --git a/packages/unplugin-vue-i18n/src/index.ts b/packages/unplugin-vue-i18n/src/index.ts index 2e15fe1..0a03893 100644 --- a/packages/unplugin-vue-i18n/src/index.ts +++ b/packages/unplugin-vue-i18n/src/index.ts @@ -121,6 +121,15 @@ export const unplugin = createUnplugin((options = {}, meta) => { debug('esm', esm) const allowDynamic = !!options.allowDynamic + debug('allowDynamic', allowDynamic) + + const strictMessage = isBoolean(options.strictMessage) + ? options.strictMessage + : true + debug('strictMessage', strictMessage) + + const escapeHtml = !!options.escapeHtml + debug('escapeHtml', escapeHtml) let isProduction = false let sourceMap = false @@ -294,6 +303,8 @@ export const unplugin = createUnplugin((options = {}, meta) => { isGlobal: globalSFCScope, useClassComponent, allowDynamic, + strictMessage, + escapeHtml, bridge, exportESM: esm, forceStringify @@ -449,6 +460,8 @@ export const unplugin = createUnplugin((options = {}, meta) => { { forceStringify, bridge, + strictMessage, + escapeHtml, exportESM: esm, useClassComponent } @@ -500,6 +513,8 @@ export const unplugin = createUnplugin((options = {}, meta) => { isGlobal: globalSFCScope, useClassComponent, allowDynamic, + strictMessage, + escapeHtml, bridge, exportESM: esm, forceStringify @@ -555,6 +570,8 @@ export const unplugin = createUnplugin((options = {}, meta) => { isGlobal: globalSFCScope, useClassComponent, bridge, + strictMessage, + escapeHtml, exportESM: esm, forceStringify } @@ -644,12 +661,16 @@ async function generateBundleResources( isGlobal = false, bridge = false, exportESM = true, + strictMessage = true, + escapeHtml = false, useClassComponent = false }: { forceStringify?: boolean isGlobal?: boolean bridge?: boolean exportESM?: boolean + strictMessage?: boolean + escapeHtml?: boolean useClassComponent?: boolean } ) { @@ -666,6 +687,8 @@ async function generateBundleResources( useClassComponent, bridge, exportESM, + strictMessage, + escapeHtml, forceStringify }) as CodeGenOptions parseOptions.type = 'bare' @@ -743,7 +766,9 @@ function getOptions( bridge = false, exportESM = true, useClassComponent = false, - allowDynamic = false + allowDynamic = false, + strictMessage = true, + escapeHtml = false }: { inSourceMap?: RawSourceMap forceStringify?: boolean @@ -752,6 +777,8 @@ function getOptions( exportESM?: boolean useClassComponent?: boolean allowDynamic?: boolean + strictMessage?: boolean + escapeHtml?: boolean } ): Record { const mode: DevEnv = isProduction ? 'production' : 'development' @@ -763,6 +790,8 @@ function getOptions( forceStringify, useClassComponent, allowDynamic, + strictMessage, + escapeHtml, bridge, exportESM, env: mode, diff --git a/packages/unplugin-vue-i18n/src/types.ts b/packages/unplugin-vue-i18n/src/types.ts index de766dc..79b30df 100644 --- a/packages/unplugin-vue-i18n/src/types.ts +++ b/packages/unplugin-vue-i18n/src/types.ts @@ -12,4 +12,6 @@ export interface PluginOptions { bridge?: boolean useClassComponent?: boolean useVueI18nImportName?: boolean + strictMessage?: boolean + escapeHtml?: boolean } diff --git a/packages/unplugin-vue-i18n/test/fixtures/locales/html.json b/packages/unplugin-vue-i18n/test/fixtures/locales/html.json new file mode 100644 index 0000000..09b59a5 --- /dev/null +++ b/packages/unplugin-vue-i18n/test/fixtures/locales/html.json @@ -0,0 +1,4 @@ +{ + "hi": "

hi there!

", + "alert": "" +} diff --git a/packages/unplugin-vue-i18n/test/utils.ts b/packages/unplugin-vue-i18n/test/utils.ts index abe5cb5..5da6694 100644 --- a/packages/unplugin-vue-i18n/test/utils.ts +++ b/packages/unplugin-vue-i18n/test/utils.ts @@ -38,6 +38,10 @@ export async function bundleVite( ? 'info' : 'silent' : 'silent' + options.strictMessage = isBoolean(options.strictMessage) + ? options.strictMessage + : true + options.escapeHtml = !!options.escapeHtml const alias: Record = { vue: 'vue/dist/vue.runtime.esm-browser.js' @@ -154,6 +158,10 @@ export async function bundleAndRun( options.useClassComponent = isBoolean(options.useClassComponent) || false options.bridge = isBoolean(options.bridge) || false options.allowDynamic = isBoolean(options.allowDynamic) || false + options.strictMessage = isBoolean(options.strictMessage) + ? options.strictMessage + : true + options.escapeHtml = !!options.escapeHtml const { code, map } = await bundler(fixture, options) diff --git a/packages/unplugin-vue-i18n/test/vite/bundle-import.test.ts b/packages/unplugin-vue-i18n/test/vite/bundle-import.test.ts index fc66790..c428cb7 100644 --- a/packages/unplugin-vue-i18n/test/vite/bundle-import.test.ts +++ b/packages/unplugin-vue-i18n/test/vite/bundle-import.test.ts @@ -16,6 +16,7 @@ import { createMessageContext } from '@intlify/core-base' test(testcase, async () => { const options = { input, + strictMessage: false, include: [resolve(__dirname, '../fixtures/locales/**')] } const { exports: messages } = await bundleAndRun( diff --git a/packages/unplugin-vue-i18n/test/vite/resource-compilation.test.ts b/packages/unplugin-vue-i18n/test/vite/resource-compilation.test.ts index f9c6303..91b4ca5 100644 --- a/packages/unplugin-vue-i18n/test/vite/resource-compilation.test.ts +++ b/packages/unplugin-vue-i18n/test/vite/resource-compilation.test.ts @@ -1,8 +1,19 @@ import { resolve } from 'pathe' import { bundleVite, bundleAndRun } from '../utils' -import { isFunction } from '@intlify/shared' +import { isFunction, assign } from '@intlify/shared' import { createMessageContext } from '@intlify/core-base' +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let spyConsoleError: any +beforeEach(() => { + spyConsoleError = jest.spyOn(global.console, 'error').mockImplementation() +}) + +afterEach(() => { + spyConsoleError.mockReset() + spyConsoleError.mockRestore() +}) + const options = { target: './fixtures/locales/', include: [resolve(__dirname, '../fixtures/locales/**')] @@ -57,3 +68,30 @@ test('dynamical resource with js / ts', async () => { }) expect(isFunction(module)).toBe(true) }) + +test('strict message', async () => { + let occured = false + try { + await bundleAndRun('html.json', bundleVite, { + ...options + }) + } catch (e) { + occured = true + } + expect(occured).toBe(true) +}) + +test('escape message', async () => { + const { module } = await bundleAndRun( + 'html.json', + bundleVite, + assign(options, { + strictMessage: false, + escapeHtml: true + }) + ) + expect(module.hi(createMessageContext())).toBe(`<p>hi there!</p>`) + expect(module.alert(createMessageContext())).toBe( + `<script>window.alert('hi there!')</script>` + ) +}) diff --git a/yarn.lock b/yarn.lock index 9637afa..0c5cfd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -969,7 +969,7 @@ __metadata: languageName: unknown linkType: soft -"@intlify/bundle-utils@^5.3.1, @intlify/bundle-utils@workspace:packages/bundle-utils": +"@intlify/bundle-utils@^5.4.0, @intlify/bundle-utils@workspace:packages/bundle-utils": version: 0.0.0-use.local resolution: "@intlify/bundle-utils@workspace:packages/bundle-utils" dependencies: @@ -1114,7 +1114,7 @@ __metadata: version: 0.0.0-use.local resolution: "@intlify/unplugin-vue-i18n@workspace:packages/unplugin-vue-i18n" dependencies: - "@intlify/bundle-utils": ^5.3.1 + "@intlify/bundle-utils": ^5.4.0 "@intlify/shared": 9.3.0-beta.17 "@rollup/pluginutils": ^5.0.2 "@vue/compiler-sfc": ^3.2.47