diff --git a/src/client/updaters/tag.js b/src/client/updaters/tag.js index d51533f6..c4635e26 100644 --- a/src/client/updaters/tag.js +++ b/src/client/updaters/tag.js @@ -56,6 +56,11 @@ export default function updateTag (appId, options = {}, type, tags, head, body) continue } + if (attr === 'json') { + newElement.innerHTML = JSON.stringify(tag.json) + continue + } + if (attr === 'cssText') { if (newElement.styleSheet) { /* istanbul ignore next */ diff --git a/src/server/generators/tag.js b/src/server/generators/tag.js index fcb315da..f92ed1db 100644 --- a/src/server/generators/tag.js +++ b/src/server/generators/tag.js @@ -62,8 +62,13 @@ export default function tagGenerator ({ ssrAppId, attribute, tagIDKeyName } = {} attrs += ` ${prefix}${attr}` + (isBooleanAttr ? '' : `="${tag[attr]}"`) } + let json = '' + if (tag.json) { + json = JSON.stringify(tag.json) + } + // grab child content from one of these attributes, if possible - const content = tag.innerHTML || tag.cssText || '' + const content = tag.innerHTML || tag.cssText || json // generate tag exactly without any other redundant attribute diff --git a/src/shared/constants.js b/src/shared/constants.js index a347b246..abb59503 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -90,7 +90,7 @@ export const tagsWithoutEndTag = ['base', 'meta', 'link'] export const tagsWithInnerContent = ['noscript', 'script', 'style'] // Attributes which are inserted as childNodes instead of HTMLAttribute -export const tagAttributeAsInnerContent = ['innerHTML', 'cssText'] +export const tagAttributeAsInnerContent = ['innerHTML', 'cssText', 'json'] // Attributes which should be added with data- prefix export const commonDataAttributes = ['body', 'pbody'] diff --git a/src/shared/escaping.js b/src/shared/escaping.js index 8f9b123b..9afd6770 100644 --- a/src/shared/escaping.js +++ b/src/shared/escaping.js @@ -21,7 +21,7 @@ export const clientSequences = [ // sanitizes potentially dangerous characters export function escape (info, options, escapeOptions) { const { tagIDKeyName } = options - const { doEscape = v => v } = escapeOptions + const { doEscape = v => v, escapeKeys } = escapeOptions const escaped = {} for (const key in info) { @@ -55,15 +55,25 @@ export function escape (info, options, escapeOptions) { escaped[key] = doEscape(value) } else if (isArray(value)) { escaped[key] = value.map((v) => { - return isPureObject(v) - ? escape(v, options, escapeOptions) - : doEscape(v) + if (isPureObject(v)) { + return escape(v, options, { ...escapeOptions, escapeKeys: true }) + } + + return doEscape(v) }) } else if (isPureObject(value)) { - escaped[key] = escape(value, options, escapeOptions) + escaped[key] = escape(value, options, { ...escapeOptions, escapeKeys: true }) } else { escaped[key] = value } + + if (escapeKeys) { + const escapedKey = doEscape(key) + if (key !== escapedKey) { + escaped[escapedKey] = escaped[key] + delete escaped[key] + } + } } return escaped diff --git a/test/unit/escaping.test.js b/test/unit/escaping.test.js index e4775a72..aff3fef3 100644 --- a/test/unit/escaping.test.js +++ b/test/unit/escaping.test.js @@ -1,6 +1,7 @@ import _getMetaInfo from '../../src/shared/getMetaInfo' import { loadVueMetaPlugin } from '../utils' import { defaultOptions } from '../../src/shared/constants' +import { serverSequences } from '../../src/shared/escaping' const getMetaInfo = (component, escapeSequences) => _getMetaInfo(defaultOptions, component, escapeSequences) @@ -96,4 +97,43 @@ describe('escaping', () => { __dangerouslyDisableSanitizersByTagID: { noscape: ['innerHTML'] } }) }) + + test('json is still safely escaped', () => { + const component = new Vue({ + metaInfo: { + script: [ + { + json: { + perfectlySave: '

This is safe

unsafeKey': 'This is also still safe' + } + } + ] + } + }) + + expect(getMetaInfo(component, serverSequences)).toEqual({ + title: undefined, + titleChunk: '', + titleTemplate: '%s', + htmlAttrs: {}, + headAttrs: {}, + bodyAttrs: {}, + meta: [], + base: [], + link: [], + style: [], + script: [ + { + json: { + perfectlySave: '</script><p class="unsafe">This is safe</p><script>', + '</script>unsafeKey': 'This is also still safe' + } + } + ], + noscript: [], + __dangerouslyDisableSanitizers: [], + __dangerouslyDisableSanitizersByTagID: {} + }) + }) }) diff --git a/test/utils/meta-info-data.js b/test/utils/meta-info-data.js index 82e42764..a0a65e4f 100644 --- a/test/utils/meta-info-data.js +++ b/test/utils/meta-info-data.js @@ -116,12 +116,22 @@ const metaInfoData = { { src: 'src1', async: false, defer: true, [defaultOptions.tagIDKeyName]: 'content', callback: () => {} }, { src: 'src-prepend', async: true, defer: false, pbody: true }, { src: 'src2', async: false, defer: true, body: true }, - { src: 'src3', async: false, skip: true } + { src: 'src3', async: false, skip: true }, + { type: 'application/ld+json', + json: { + '@context': 'http://schema.org', + '@type': 'Organization', + 'name': 'MyApp', + 'url': 'https://www.myurl.com', + 'logo': 'https://www.myurl.com/images/logo.png' + } + } ], expect: [ '', '', - '' + '', + '' ], test (side, defaultTest) { return () => { @@ -139,12 +149,13 @@ const metaInfoData = { // ssr doesnt generate data-body tags const bodyPrepended = this.expect[1] const bodyAppended = this.expect[2] - this.expect = [this.expect[0]] + this.expect = [this.expect.shift(), this.expect.pop()] const tags = defaultTest() + const html = tags.text() - expect(tags.text()).not.toContain(bodyPrepended) - expect(tags.text()).not.toContain(bodyAppended) + expect(html).not.toContain(bodyPrepended) + expect(html).not.toContain(bodyAppended) } } }