From 881c4c22dad24d776523697a894d52e82ec9888a Mon Sep 17 00:00:00 2001 From: Adam DeHaven Date: Wed, 14 Oct 2026 17:26:58 -0400 Subject: [PATCH 1/2] feat(renderer): interpolate placeholders in bound props Resolve placeholders such as path or default in bound string props from document, ancestor and slot data. The result is always a string. Placeholders read own properties only and never call functions. The assembled value is checked before use, and an unsafe prop is removed with a warning. Malformed templates and JSON values keep the old behavior. Also treat malformed percent encoding in link props as unsafe instead of throwing. Closes #515 --- README.md | 50 ++ docs/0.index.md | 50 ++ src/runtime/components/MDCRenderer.vue | 28 +- src/runtime/parser/utils/props.ts | 35 +- src/runtime/utils/template.ts | 225 +++++++ test/basic.test.ts | 226 +++++++ .../basic/app/components/mdc/PropProbe.vue | 11 + .../basic/app/pages/props-interpolation.vue | 17 + test/markdown/xss.test.ts | 49 +- test/utils/template.test.ts | 634 ++++++++++++++++++ 10 files changed, 1316 insertions(+), 9 deletions(-) create mode 100644 src/runtime/utils/template.ts create mode 100644 test/fixtures/basic/app/components/mdc/PropProbe.vue create mode 100644 test/fixtures/basic/app/pages/props-interpolation.vue create mode 100644 test/utils/template.test.ts diff --git a/README.md b/README.md index 11d7379b..64a72b46 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,56 @@ const { data: ast } = await useAsyncData('markdown', () => parseMarkdown('::aler ``` +#### Dynamic props with interpolation + +Props with a `:` or `v-bind:` prefix can mix fixed text with `{{ }}` placeholders. MDC resolves each placeholder from your document data and passes the result to the component as a string. + +```md +--- +snippet: + api: + id: abc123 +--- + +::button +--- +:to: https://apiconsole.example.com/repository/{{ snippet.api.id }} +:title: Version {{ snippet.api.version || '1.0' }} +--- +Open the API console +:: + +:a[Read more]{:href="/docs/{{ doc.slug }}/overview"} +``` + +A placeholder is a dotted path, with optional indexes (`items[0].name`) and an optional default (`|| 'text'`). It reads document data, ancestor props and slot props, but not sibling props. Use `$doc.key` when an ancestor prop hides a document key. + +- **Values:** strings, finite numbers and booleans are inserted as text. Anything else uses the default, or an empty string. +- **No encoding:** values are not URL-encoded or escaped. Only insert data you trust, especially for props like `id` or `name`. +- **Safe lookups:** placeholders read own properties only and never call functions. They cannot read `__proto__`, `constructor`, `prototype`, `$route`, `runtimeData`, `updateRuntimeData` or a root `tags`. +- **Stale values:** like `{{ }}` in text, placeholders read the data passed to the renderer. A value changed only through `v-model` can look stale. +- **Quoting:** in YAML, quote values that start with `{{`, or that contain `: ` or ` #`. Inline attributes must always be quoted. + +##### Left as written + +MDC does not interpolate these values. They keep their original behavior, so a typo stays visible as literal text: + +- Props without the `:` prefix, and the props `key`, `ref`, `is` and `style`. +- Malformed templates, such as `{{ a | upper }}` or `{{ a + b }}`. +- Templates over 4096 characters, 16 placeholders, or 256 characters per placeholder. +- JSON arrays and objects. MDC passes YAML arrays and objects as JSON, so `items: ["{{ x }}"]` stays an array. + +##### Removed with a warning + +MDC checks the final string and removes the prop when it is unsafe. The console warning names the prop only. This happens when: + +- the result is over 4096 characters. +- the prop name is unsafe, such as a name that starts with `on`, or `srcdoc`. +- the value starts with an unsafe link prefix in any prop, such as `javascript:`, `vbscript:` or `data:text/html`. +- an `href`, `src`, `action` or `xlinkHref` value has a malformed `%` sequence. + +**Existing content:** a `:` prop that already contained a well-formed `{{ }}` now interpolates. For literal braces, use a prop without the `:` prefix. + ### `` This component is a replacement for Vue's `` component, specifically designed for MDC. Using this component, you can render a component's children while removing one or multiple wrapping elements. In the below example, the Alert component receives text and its default slot (children). But if the component renders this slot using the normal ``, it will render a `

` element around the text. diff --git a/docs/0.index.md b/docs/0.index.md index 548f6394..b0b52367 100644 --- a/docs/0.index.md +++ b/docs/0.index.md @@ -117,6 +117,56 @@ const ast = await parseMarkdown(props.md) ``` +#### Dynamic props with interpolation + +Props with a `:` or `v-bind:` prefix can mix fixed text with `{{ }}` placeholders. MDC resolves each placeholder from your document data and passes the result to the component as a string. + +```md +--- +snippet: + api: + id: abc123 +--- + +::button +--- +:to: https://apiconsole.example.com/repository/{{ snippet.api.id }} +:title: Version {{ snippet.api.version || '1.0' }} +--- +Open the API console +:: + +:a[Read more]{:href="/docs/{{ doc.slug }}/overview"} +``` + +A placeholder is a dotted path, with optional indexes (`items[0].name`) and an optional default (`|| 'text'`). It reads document data, ancestor props and slot props, but not sibling props. Use `$doc.key` when an ancestor prop hides a document key. + +- **Values:** strings, finite numbers and booleans are inserted as text. Anything else uses the default, or an empty string. +- **No encoding:** values are not URL-encoded or escaped. Only insert data you trust, especially for props like `id` or `name`. +- **Safe lookups:** placeholders read own properties only and never call functions. They cannot read `__proto__`, `constructor`, `prototype`, `$route`, `runtimeData`, `updateRuntimeData` or a root `tags`. +- **Stale values:** like `{{ }}` in text, placeholders read the data passed to the renderer. A value changed only through `v-model` can look stale. +- **Quoting:** in YAML, quote values that start with `{{`, or that contain `: ` or ` #`. Inline attributes must always be quoted. + +##### Left as written + +MDC does not interpolate these values. They keep their original behavior, so a typo stays visible as literal text: + +- Props without the `:` prefix, and the props `key`, `ref`, `is` and `style`. +- Malformed templates, such as `{{ a | upper }}` or `{{ a + b }}`. +- Templates over 4096 characters, 16 placeholders, or 256 characters per placeholder. +- JSON arrays and objects. MDC passes YAML arrays and objects as JSON, so `items: ["{{ x }}"]` stays an array. + +##### Removed with a warning + +MDC checks the final string and removes the prop when it is unsafe. The console warning names the prop only. This happens when: + +- the result is over 4096 characters. +- the prop name is unsafe, such as a name that starts with `on`, or `srcdoc`. +- the value starts with an unsafe link prefix in any prop, such as `javascript:`, `vbscript:` or `data:text/html`. +- an `href`, `src`, `action` or `xlinkHref` value has a malformed `%` sequence. + +**Existing content:** a `:` prop that already contained a well-formed `{{ }}` now interpolates. For literal braces, use a prop without the `:` prefix. + ### `` This component is a replacement for Vue's `` component, specifically designed for MDC. Using this component, you can render a component's children while removing one or multiple wrapping elements. In the below example, the Alert component receives text and its default slot (children). But if the component renders this slot using the normal ``, it will render a `

` element around the text. diff --git a/src/runtime/components/MDCRenderer.vue b/src/runtime/components/MDCRenderer.vue index abef8356..ba8bf498 100644 --- a/src/runtime/components/MDCRenderer.vue +++ b/src/runtime/components/MDCRenderer.vue @@ -9,6 +9,7 @@ import htmlTags from '../parser/utils/html-tags-list' import { flatUnwrap, nodeTextContent } from '../utils/node' import { pick } from '../utils' import { validateProps } from '../parser/utils/props' +import { isInterpolatedProp, resolvePropTemplate } from '../utils/template' type CreateElement = typeof h @@ -233,7 +234,7 @@ function _renderNode(node: MDCNode, h: CreateElement, options: MDCRenderOptions, component.tag = originalTag } - const props = propsToData(node, documentMeta) + const props = propsToData(node, documentMeta, parentScope) if (keyInParent) { props.key = keyInParent } @@ -337,8 +338,16 @@ function renderBinding(node: MDCElement, h: CreateElement, documentMeta: MDCData /** * Create component data from node props. */ -function propsToData(node: MDCElement, documentMeta: MDCData) { +function propsToData(node: MDCElement, documentMeta: MDCData, parentScope: Record = {}) { const { tag = '', props = {} } = node + // Same scope as `renderBinding`. Built once per node, and only when a template needs it. + let scope: Record | undefined + const getScope = () => scope ??= { + ...documentMeta.runtimeData, + ...parentScope, + $document: documentMeta, + $doc: documentMeta, + } return Object.keys(props).reduce(function (data, key) { // Ignore internal `__ignoreMap` prop. if (key === '__ignoreMap') { @@ -364,7 +373,7 @@ function propsToData(node: MDCElement, documentMeta: MDCData) { // `:foo="bar"`, `v-bind:foo="bar"` if (rxBind.test(key)) { - return propsToDataRxBind(key, value, data, documentMeta) + return propsToDataRxBind(key, value, data, documentMeta, getScope) } const { attribute } = find(html, key) @@ -421,8 +430,19 @@ function propsToDataRxOn(key: string, value: any, data: any, documentMeta: MDCDa /** * Handle single binding `v-bind:` and `:` */ -function propsToDataRxBind(key: string, value: any, data: any, documentMeta: MDCData) { +function propsToDataRxBind(key: string, value: any, data: any, documentMeta: MDCData, getScope: () => Record) { key = key.replace(rxBind, '') + if (isInterpolatedProp(key, value)) { + const result = resolvePropTemplate(value, getScope(), { attribute: key }) + // Remove the key, so an earlier static value does not show. + if (result === undefined) { + Reflect.deleteProperty(data, key) + } + else { + data[key] = result + } + return data + } data[key] = evalInContext(value, documentMeta) return data } diff --git a/src/runtime/parser/utils/props.ts b/src/runtime/parser/utils/props.ts index 89274781..1ad753cf 100644 --- a/src/runtime/parser/utils/props.ts +++ b/src/runtime/parser/utils/props.ts @@ -18,10 +18,35 @@ export const unsafeLinkPrefix = [ 'data:text/css', 'data:text/plain', 'data:text/xml', + 'data:application/xhtml+xml', + 'data:application/xml', ] -function isAnchorLinkAllowed(value: string) { - const decodedUrl = decodeURIComponent(value) +/** Options for `isAnchorLinkAllowed`. */ +export interface AnchorLinkOptions { + /** Accept values that are not decodable or not a valid URL. Only unsafe schemes fail. Use it for props that are not links. */ + lenient?: boolean +} + +/** + * Check if a value is a safe link target. + * + * @param value - Link value to check. + * @param options - Check options. + */ +export function isAnchorLinkAllowed(value: string, options: AnchorLinkOptions = {}) { + const { lenient = false } = options + let decodedUrl = value + try { + decodedUrl = decodeURIComponent(value) + } + catch { + // Malformed encoding is unsafe in a link. + if (!lenient) { + return false + } + } + const urlSanitized = decodedUrl.replace(/&#x([0-9a-f]+);?/gi, '') .replace(/&#(\d+);?/g, '') .replace(/&[a-z]+;?/gi, '') @@ -32,12 +57,14 @@ function isAnchorLinkAllowed(value: string) { return true } - if (unsafeLinkPrefix.some(prefix => url.href.toLowerCase().startsWith(prefix))) { + // The media type parser ignores spaces after `data:`, so `data: text/html` is still HTML. + const href = url.href.toLowerCase().replace(/^data:\s+/, 'data:') + if (unsafeLinkPrefix.some(prefix => href.startsWith(prefix))) { return false } } catch { - return false + return lenient } return true diff --git a/src/runtime/utils/template.ts b/src/runtime/utils/template.ts new file mode 100644 index 00000000..17d1bdbf --- /dev/null +++ b/src/runtime/utils/template.ts @@ -0,0 +1,225 @@ +import { isAnchorLinkAllowed, validateProp } from '../parser/utils/props' + +/** Maximum length of a template and of the assembled output. */ +export const MAX_TEMPLATE_LENGTH = 4096 + +/** Maximum number of `{{ }}` bindings in one template. */ +export const MAX_BINDINGS = 16 + +/** Maximum length of the text between `{{` and `}}`. */ +export const MAX_BINDING_LENGTH = 256 + +/** Options for `resolvePropTemplate`. */ +export interface ResolvePropTemplateOptions { + /** Prop name without the `:` or `v-bind:` prefix. Used for validation and warnings. */ + attribute: string +} + +const RESERVED_ATTRIBUTES = ['key', 'ref', 'is', 'style'] +const BLOCKED_SEGMENTS = ['__proto__', 'constructor', 'prototype', '$route', 'runtimeData', 'updateRuntimeData'] + +/** One anchored pattern with no nested quantifiers, so matching is linear. */ +const BINDING_PATTERN = /^([A-Z_$][\w$-]*(?:\.[A-Z_$][\w$-]*|\[\d+\])*)(?:[ \t]*\|\|[ \t]*(?:'([^']*)'|"([^"]*)"))?$/i + +/** Control characters allow scheme smuggling such as `java\tscript:`. */ +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/g + +/** + * Check if a value is a string with a `{{` followed later by `}}`. + * Runs in linear time. + * + * @param value - Value to check. + */ +export function hasTemplate(value: unknown): boolean { + if (typeof value !== 'string') { + return false + } + const start = value.indexOf('{{') + return start !== -1 && value.includes('}}', start + 2) +} + +/** One parsed binding. */ +interface Binding { + /** Dotted path with optional index segments. */ + path: string + /** Text used when the path does not resolve. */ + fallback: string +} + +type TemplatePart = string | Binding + +/** The parser stringifies object and array props to JSON. Those values keep their type. */ +function isStructuredJson(value: string) { + const first = value.trimStart()[0] + if (first !== '[' && first !== '{') { + return false + } + try { + return typeof JSON.parse(value) === 'object' + } + catch { + return false + } +} + +/** + * Split a template into text and bindings. + * Returns `undefined` when the template is malformed or over a limit. + * Every cap is checked before any regex runs. + */ +function parseTemplate(template: string): TemplatePart[] | undefined { + if (template.length > MAX_TEMPLATE_LENGTH) { + return undefined + } + + const bodies: Array = [] + let position = 0 + let bindings = 0 + + while (position < template.length) { + const open = template.indexOf('{{', position) + if (open === -1) { + bodies.push(template.slice(position)) + break + } + const close = template.indexOf('}}', open + 2) + if (close === -1) { + return undefined + } + bindings++ + if (bindings > MAX_BINDINGS || close - (open + 2) > MAX_BINDING_LENGTH) { + return undefined + } + if (open > position) { + bodies.push(template.slice(position, open)) + } + bodies.push({ body: template.slice(open + 2, close) }) + position = close + 2 + } + + const parts: TemplatePart[] = [] + for (const part of bodies) { + if (typeof part === 'string') { + parts.push(part) + continue + } + const match = BINDING_PATTERN.exec(part.body.trim()) + if (!match) { + return undefined + } + parts.push({ path: match[1]!, fallback: match[2] ?? match[3] ?? '' }) + } + return parts +} + +/** + * Check if a prop value must be interpolated. + * Other values keep the legacy behavior. These are: + * - Reserved attributes `key`, `ref`, `is` and `style`. + * - JSON object and array values. + * - Malformed templates and templates over a limit. + * + * @param attribute - Prop name without the `:` or `v-bind:` prefix. + * @param value - Prop value. + */ +export function isInterpolatedProp(attribute: string, value: unknown): boolean { + const name = attribute.toLowerCase().replace(/^[.^]/, '') + return !RESERVED_ATTRIBUTES.includes(name) + && hasTemplate(value) + && !isStructuredJson(value as string) + && parseTemplate(value as string) !== undefined +} + +function isBlocked(segments: string[], index: number) { + const segment = segments[index]! + if (BLOCKED_SEGMENTS.includes(segment)) { + return true + } + if (segment !== 'tags') { + return false + } + return index === 0 || (index === 1 && (segments[0] === '$doc' || segments[0] === '$document')) +} + +function resolvePath(path: string, context: Record): string | undefined { + const segments = path.split(/\.|\[(\d+)\]/).filter(Boolean) + let current: unknown = context + + for (let index = 0; index < segments.length; index++) { + if (isBlocked(segments, index)) { + return undefined + } + if (typeof current !== 'object' || current === null) { + return undefined + } + const segment = segments[index]! + // `in` tracks the key on Vue proxies. A getter on the prototype chain never runs. + if (!(segment in current) || !Object.prototype.hasOwnProperty.call(current, segment)) { + return undefined + } + current = (current as Record)[segment] + } + + if (typeof current === 'string') { + return current + } + if ((typeof current === 'number' && Number.isFinite(current)) || typeof current === 'boolean') { + return String(current) + } + return undefined +} + +function drop(attribute: string): undefined { + console.warn('[@nuxtjs/mdc] removing invalid or unsafe dynamic attribute: ' + attribute) + return undefined +} + +/** + * Replace `{{ path || 'default' }}` bindings in a prop template with context values. + * The result is always a string. No JavaScript runs. + * Values are inserted once and never evaluated again. + * + * @param template - Prop value that contains bindings. + * @param context - Data used to resolve paths. The function does not change it. + * @param options - Resolve options. + * @returns The assembled string, or `undefined` when the prop must be dropped. + */ +export function resolvePropTemplate( + template: string, + context: Record, + options: ResolvePropTemplateOptions, +): string | undefined { + const { attribute } = options + + const parts = parseTemplate(template) + if (!parts) { + return drop(attribute) + } + + // Context data is not trusted. A getter or proxy trap can throw. + try { + let output = '' + for (const part of parts) { + if (typeof part === 'string') { + output += part + continue + } + const value = resolvePath(part.path, context) ?? part.fallback + output += value.replace(CONTROL_CHARACTERS, '') + // Stop early, so a huge value cannot grow the output. + if (output.length > MAX_TEMPLATE_LENGTH) { + return drop(attribute) + } + } + + // `validateProp` stays strict for link props. The scheme check is lenient so text such as `50%` survives. + if (!validateProp(attribute, output) || !isAnchorLinkAllowed(output, { lenient: true })) { + return drop(attribute) + } + return output + } + catch { + return drop(attribute) + } +} diff --git a/test/basic.test.ts b/test/basic.test.ts index c3d7b4eb..28b20859 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -67,4 +67,230 @@ describe('ssr', async () => { expect(html).toMatch(/]*id="safe-root"/) }) }) + + describe('prop interpolation', () => { + const render = async (markdown: string, frontmatter = '') => { + const md = frontmatter ? `---\n${frontmatter}\n---\n\n${markdown}` : markdown + const html = await $fetch('/props-interpolation', { query: { md } }) + // only the rendered markup; the payload also appears in the serialized `__NUXT_DATA__` + return html.slice(html.indexOf('id="props-interpolation"'), html.indexOf('

html.match(new RegExp(`<${tag}\\b[^>]*>`))?.[0] ?? '' + const docSlug = 'doc:\n slug: getting-started' + + it('interpolates a placeholder in :href', async () => { + const html = await render(':a[Read more]{:href="/docs/{{ doc.slug }}/overview"}', docSlug) + expect(openingTag(html, 'a')).toContain('href="/docs/getting-started/overview"') + }) + + it('interpolates an expression with a fallback', async () => { + const markdown = ':span{:title="Version {{ v.n || \'1.0\' }}"}' + const fallback = await render(markdown) + expect(openingTag(fallback, 'span')).toContain('title="Version 1.0"') + + const html = await render(markdown, 'v:\n n: "2.1"') + expect(openingTag(html, 'span')).toContain('title="Version 2.1"') + }) + + it('interpolates two placeholders in one value', async () => { + const html = await render(':span{:title="{{ a }}-{{ b }}"}', 'a: x\nb: y') + expect(openingTag(html, 'span')).toContain('title="x-y"') + }) + + it('keeps braces in a non-: prop (regression guard)', async () => { + const html = await render(':span{title="{{ doc.slug }}"}', docSlug) + expect(openingTag(html, 'span')).toContain('{{ doc.slug }}') + }) + + it('still resolves a plain :prop expression (regression guard)', async () => { + const html = await render(':span{:title="doc.slug"}', docSlug) + expect(openingTag(html, 'span')).toContain('title="getting-started"') + }) + + it('drops an unsafe href from interpolated data', async () => { + const html = await render(':a[x]{:href="{{ link }}"}', 'link: "javascript:alert(1)"') + const tag = openingTag(html, 'a') + expect(tag).toMatch(/^]*>()?x()?<\/a>/) + expect(html).not.toContain('javascript:alert') + }) + + it('drops href and does not fail on malformed encoding', async () => { + const html = await render(':a[x]{:href="{{ link }}"}', 'link: "%"') + const tag = openingTag(html, 'a') + expect(tag).toMatch(/^]*>()?x()?<\/a>/) + }) + + it('does not expose internal data such as $route', async () => { + const html = await render(':span{:title="a{{ $route.path }}b"}') + expect(openingTag(html, 'span')).toContain('title="ab"') + }) + + it('does not evaluate placeholders inside interpolated values', async () => { + const html = await render(':span{:title="t-{{ a }}"}', 'a: "{{ b }}"\nb: secret') + const tag = openingTag(html, 'span') + expect(tag).toContain('title="t-{{ b }}"') + expect(html).not.toContain('secret') + }) + + it('supports the v-bind: prefix', async () => { + const html = await render(':span{v-bind:title="/p/{{ doc.slug }}"}', docSlug) + expect(openingTag(html, 'span')).toContain('title="/p/getting-started"') + }) + + describe('YAML block props', () => { + const snippet = 'snippet:\n api:\n id: abc123' + const block = (prop: string) => `::div\n---\n${prop}\n---\nbody\n::` + // MDC wraps the output in its own `
`, so read the block element inside it. + const blockTag = (html: string) => openingTag(html.slice(html.indexOf('
') + 1), 'div') + + it('interpolates a template inside a longer value', async () => { + const html = await render(block(':title: https://apiconsole.example.com/repository/{{ snippet.api.id }}'), snippet) + expect(blockTag(html).match(/title="[^"]*"/)?.[0]).toBe('title="https://apiconsole.example.com/repository/abc123"') + }) + + it('keeps braces in a non-: YAML prop (negative twin)', async () => { + const html = await render(block('title: https://apiconsole.example.com/repository/{{ snippet.api.id }}'), snippet) + const tag = blockTag(html) + expect(tag).toContain('{{ snippet.api.id }}') + expect(tag).not.toContain('abc123') + }) + + it('resolves a quoted value that starts with a placeholder', async () => { + const html = await render(block(':title: "{{ snippet.api.id }}"'), snippet) + expect(blockTag(html)).toContain('title="abc123"') + }) + + it('uses the fallback when the path is missing', async () => { + const markdown = block(':title: Version {{ snippet.api.version || \'1.0\' }}') + const html = await render(markdown, snippet) + expect(blockTag(html)).toContain('title="Version 1.0"') + + const set = await render(markdown, 'snippet:\n api:\n version: "2.1"') + expect(blockTag(set)).toContain('title="Version 2.1"') + }) + }) + + it('resolves the $doc. prefix', async () => { + const html = await render(':span{:title="{{ $doc.doc.slug }}"}', docSlug) + expect(openingTag(html, 'span')).toContain('title="getting-started"') + }) + + it('resolves the $document. prefix', async () => { + const html = await render(':span{:title="{{ $document.doc.slug }}"}', docSlug) + expect(openingTag(html, 'span')).toContain('title="getting-started"') + }) + + it('does not expose $doc.tags or $doc.runtimeData', async () => { + const html = await render(':span{:title="a{{ $doc.tags.p }}b{{ $doc.runtimeData.doc.slug }}c"}', docSlug) + expect(openingTag(html, 'span')).toContain('title="abc"') + }) + + describe('ancestor scope', () => { + const nested = '::div{title="T"}\n:span{:title="{{ title }}"}\n::' + + it('reads a prop of an ancestor element', async () => { + const html = await render(nested) + expect(openingTag(html, 'span')).toContain('title="T"') + }) + + it('lets an ancestor prop shadow a document key', async () => { + const html = await render(nested, 'title: DOC') + expect(openingTag(html, 'span')).toContain('title="T"') + expect(html).not.toContain('title="DOC"') + }) + + it('still reads the document key through $doc when shadowed', async () => { + const html = await render('::div{title="T"}\n:span{:title="{{ $doc.title }}"}\n::', 'title: DOC') + expect(openingTag(html, 'span')).toContain('title="DOC"') + }) + + it('reads the document key when no ancestor sets the prop (negative twin)', async () => { + const html = await render(':span{:title="{{ title }}"}', 'title: DOC') + expect(openingTag(html, 'span')).toContain('title="DOC"') + }) + }) + + it.each([ + ':span{v-bind:title="{{ bad }}" title="static"}', + ':span{title="static" v-bind:title="{{ bad }}"}', + ])('keeps a dropped prop absent even with a static twin: %s', async (markdown) => { + const html = await render(markdown, 'bad: "javascript:alert(1)"') + const tag = openingTag(html, 'span') + expect(tag).toMatch(/^ { + const html = await render(':span{:title="{{ bad }}" id="kept"}', 'bad: "javascript:alert(1)"') + const tag = openingTag(html, 'span') + expect(tag).toContain('id="kept"') + expect(tag).not.toContain('title') + expect(html).not.toContain('javascript:') + }) + + it.each(['onclick', 'srcdoc'])('does not render an unsafe attribute name: %s', async (name) => { + const html = await render(`:span{:${name}="{{ x }}" id="kept"}`, 'x: "alert(1)"') + const tag = openingTag(html, 'span') + expect(tag).toContain('id="kept"') + expect(tag).not.toContain(name) + expect(html).not.toContain('alert(1)') + }) + + it('still interpolates a well-formed placeholder in a plain string (regression guard)', async () => { + const html = await render(':span{:title="Hi {{ name }}"}', 'name: Bob') + expect(openingTag(html, 'span')).toContain('title="Hi Bob"') + }) + + it.each([ + 'Use {{ name | upper }}', + 'Use {{ a + b }}', + ])('keeps a malformed template as a literal string: %s', async (value) => { + const html = await render(`:span{:title="${value}"}`, 'name: Bob\na: x\nb: y') + const tag = openingTag(html, 'span') + expect(tag).toContain(`title="${value}"`) + expect(tag).not.toContain('Bob') + expect(tag).not.toContain('xy') + }) + + // `PropProbe` renders the type and the JSON of its props. SSR drops array and object attributes on a plain element. + describe('structured YAML props', () => { + const probe = (prop: string) => `::prop-probe\n---\n${prop}\n---\n::` + const probeText = (html: string) => html.match(/

([\s\S]*?)<\/p>/)?.[1]?.replace(/"/g, '"') ?? '' + + it('keeps a JSON array prop as an array', async () => { + const text = probeText(await render(probe('items:\n - "{{ name }}"'), 'name: Bob')) + expect(text).toContain('items:array') + expect(text).toContain('{"items":["{{ name }}"]}') + expect(text).not.toContain('Bob') + }) + + it('keeps a JSON object prop as an object', async () => { + const text = probeText(await render(probe('data:\n template: "Hello {{ name }}"'), 'name: Bob')) + expect(text).toContain('data:object') + expect(text).toContain('{"data":{"template":"Hello {{ name }}"}}') + expect(text).not.toContain('Bob') + }) + }) + + it('does not interpolate the reserved :style prop', async () => { + const html = await render(':span{:style="color:{{ c }}" id="kept"}', 'c: red') + const tag = openingTag(html, 'span') + expect(tag).toContain('id="kept"') + expect(tag).toContain('style="color:{{ c }}"') + expect(tag).not.toContain('red') + }) + + it('does not interpolate the reserved :is prop', async () => { + const html = await render(':span{:is="{{ c }}" id="kept"}', 'c: strong') + const tag = openingTag(html, 'span') + expect(tag).toContain('id="kept"') + expect(tag).toContain('is="{{ c }}"') + expect(html).not.toMatch(/ +

+ items:{{ Array.isArray(items) ? 'array' : typeof items }} + data:{{ Array.isArray(data) ? 'array' : typeof data }} + json:{{ JSON.stringify({ items, data }) }} +

+ + + diff --git a/test/fixtures/basic/app/pages/props-interpolation.vue b/test/fixtures/basic/app/pages/props-interpolation.vue new file mode 100644 index 00000000..f26f4c9d --- /dev/null +++ b/test/fixtures/basic/app/pages/props-interpolation.vue @@ -0,0 +1,17 @@ + + + diff --git a/test/markdown/xss.test.ts b/test/markdown/xss.test.ts index 22f3db4e..2acd9cca 100644 --- a/test/markdown/xss.test.ts +++ b/test/markdown/xss.test.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest' import { parseMarkdown } from '../utils/parser' import type { MDCElement } from '../../src/types' -import { validateProp, validateProps } from '../../src/runtime/parser/utils/props' +import { isAnchorLinkAllowed, validateProp, validateProps } from '../../src/runtime/parser/utils/props' const md = `\ @@ -197,3 +197,50 @@ it('should strip v-bind directive-form unsafe attributes from parsed HTML', asyn expect(a.props?.href ?? a.props?.['v-bind:href']).toBeUndefined() }) + +it('should treat malformed percent-encoding in link attributes as unsafe', async () => { + expect(validateProp('href', '%')).toBe(false) + expect(validateProp('src', '/a/%E0%A4%A')).toBe(false) + + const { body } = await parseMarkdown(':a{href="%"}\n\n[x](/%E0%A4%A)') + const inline = body.children[0] as MDCElement + const link = (body.children[1] as MDCElement).children?.[0] as MDCElement + + expect(inline.props?.href).toBeUndefined() + expect(link.props?.href).toBeUndefined() +}) + +it('should support a lenient mode for non-link values', () => { + expect(isAnchorLinkAllowed('Save 50%', { lenient: true })).toBe(true) + expect(isAnchorLinkAllowed('http://', { lenient: true })).toBe(true) + expect(isAnchorLinkAllowed('Save 50%')).toBe(false) + expect(isAnchorLinkAllowed('javascript:alert(1)%', { lenient: true })).toBe(false) + expect(isAnchorLinkAllowed('data:text/html,x%', { lenient: true })).toBe(false) +}) + +it.each([ + 'data: text/html,', + 'data: text/html,', + 'data:%20text/html,', + 'DATA: TEXT/HTML,', + 'data:\ttext/html,', + 'data: application/xhtml+xml,', + 'data:application/xhtml+xml,', + 'data:application/xml,', +])('should block script-capable data URLs, including whitespace after the colon: %j', (value) => { + for (const attribute of ['href', 'src', 'action', ':src', 'v-bind:href']) { + expect(validateProp(attribute, value)).toBe(false) + } +}) + +it('should keep allowing data image URLs', () => { + expect(validateProp('src', 'data:image/png;base64,iVBORw0KGgo=')).toBe(true) + expect(validateProp('src', 'data:image/svg+xml,')).toBe(true) +}) + +it('should strip script-capable data URLs with whitespace from parsed content', async () => { + const { body } = await parseMarkdown(':iframe{src="data: text/html,"}') + const iframe = body.children[0] as MDCElement + + expect(iframe.props?.src).toBeUndefined() +}) diff --git a/test/utils/template.test.ts b/test/utils/template.test.ts new file mode 100644 index 00000000..522e8cee --- /dev/null +++ b/test/utils/template.test.ts @@ -0,0 +1,634 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { reactive, watchEffect } from 'vue' +import { + MAX_BINDING_LENGTH, + MAX_BINDINGS, + MAX_TEMPLATE_LENGTH, + hasTemplate, + isInterpolatedProp, + resolvePropTemplate, +} from '../../src/runtime/utils/template' + +const WARN_PREFIX = '[@nuxtjs/mdc] removing invalid or unsafe dynamic attribute: ' + +let warn: ReturnType + +beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + warn.mockRestore() +}) + +const resolve = (template: string, context: Record = {}, attribute = 'to') => + resolvePropTemplate(template, context, { attribute }) + +describe('limits', () => { + it('exports the documented limits', () => { + expect(MAX_TEMPLATE_LENGTH).toBe(4096) + expect(MAX_BINDINGS).toBe(16) + expect(MAX_BINDING_LENGTH).toBe(256) + }) +}) + +describe('hasTemplate', () => { + it('is true for a string with a complete binding', () => { + expect(hasTemplate('a {{ b }}')).toBe(true) + }) + + it('is false for non-strings', () => { + expect(hasTemplate(1)).toBe(false) + expect(hasTemplate(null)).toBe(false) + expect(hasTemplate(undefined)).toBe(false) + expect(hasTemplate({})).toBe(false) + expect(hasTemplate(['{{ a }}'])).toBe(false) + }) + + it('is false without braces or with incomplete braces', () => { + expect(hasTemplate('no braces')).toBe(false) + expect(hasTemplate('{{ unterminated')).toBe(false) + expect(hasTemplate('}} {{')).toBe(false) + expect(hasTemplate('{{')).toBe(false) + }) +}) + +describe('isInterpolatedProp', () => { + it.each(['key', 'ref', 'is', 'style', '.style', '^is', 'STYLE', '.Key', '^REF'])('rejects reserved attribute %s', (name) => { + expect(isInterpolatedProp(name, '{{ a }}')).toBe(false) + }) + + it.each(['to', 'title', '.href', '^title', 'data-key'])('accepts attribute %s with a template', (name) => { + expect(isInterpolatedProp(name, 'x {{ a }}')).toBe(true) + }) + + it('rejects values without a template', () => { + expect(isInterpolatedProp('to', 'plain')).toBe(false) + expect(isInterpolatedProp('to', 1)).toBe(false) + }) +}) + +describe('basics', () => { + it('resolves a suffix binding', () => { + expect(resolve('https://h/r/{{a.id}}', { a: { id: 'x1' } })).toBe('https://h/r/x1') + }) + + it('resolves a binding in the middle', () => { + expect(resolve('/x/{{a}}/y', { a: 'm' })).toBe('/x/m/y') + }) + + it('resolves a binding at the start', () => { + expect(resolve('{{a}}/x', { a: 'm' })).toBe('m/x') + }) + + it('resolves multiple bindings', () => { + expect(resolve('/{{a}}/{{b}}/{{a}}', { a: '1', b: '2' })).toBe('/1/2/1') + }) + + it('accepts whitespace variants', () => { + expect(resolve('/{{a}}', { a: 'v' })).toBe('/v') + expect(resolve('/{{ a }}', { a: 'v' })).toBe('/v') + expect(resolve('/{{\ta\t}}', { a: 'v' })).toBe('/v') + }) + + it('does not warn on success', () => { + resolve('/{{a}}', { a: 'v' }) + expect(warn).not.toHaveBeenCalled() + }) + + it('does not mutate the context', () => { + const context = Object.freeze({ a: Object.freeze({ b: 'c' }) }) + expect(resolve('/{{a.b}}', context)).toBe('/c') + }) +}) + +describe('defaults', () => { + it('uses a single quoted default for a missing value', () => { + expect(resolve('/{{ a || \'d\' }}', {})).toBe('/d') + }) + + it('uses a double quoted default for a missing value', () => { + expect(resolve('/{{ a || "d" }}', {})).toBe('/d') + }) + + it('returns an empty string for a missing value without default', () => { + expect(resolve('/x/{{ a }}', {})).toBe('/x/') + }) + + it('allows special characters in a default', () => { + expect(resolve('/{{ a || \'{}\' }}', {})).toBe('/{}') + expect(resolve('/{{ a || \'a|b\' }}', {})).toBe('/a|b') + expect(resolve('/{{ a || \'x || y\' }}', {})).toBe('/x || y') + expect(resolve('/{{ a || "x || y" }}', {})).toBe('/x || y') + }) + + it('does not use the default when the value resolves', () => { + expect(resolve('/{{ a || \'d\' }}', { a: 'v' })).toBe('/v') + }) + + it('keeps a resolved empty string', () => { + expect(resolve('/{{ a || \'d\' }}', { a: '' })).toBe('/') + }) + + it('accepts no space around the operator', () => { + expect(resolve('/{{a||\'d\'}}', {})).toBe('/d') + }) + + it('strips control characters from a default', () => { + expect(resolve('/{{ a || \'x\ty\' }}', {})).toBe('/xy') + }) +}) + +describe('paths', () => { + it('resolves array indexes', () => { + expect(resolve('/{{ list[0].name }}', { list: [{ name: 'first' }, { name: 'second' }] })).toBe('/first') + expect(resolve('/{{ list[1].name }}', { list: [{ name: 'first' }, { name: 'second' }] })).toBe('/second') + }) + + it('resolves $doc and $document prefixes', () => { + const context = { $doc: { title: 'T' }, $document: { title: 'T' } } + expect(resolve('/{{ $doc.title }}', context)).toBe('/T') + expect(resolve('/{{ $document.title }}', context)).toBe('/T') + }) + + it('resolves hyphenated keys', () => { + expect(resolve('/{{ my-key.x }}', { 'my-key': { x: 'h' } })).toBe('/h') + }) + + it('resolves nested frontmatter tags', () => { + expect(resolve('/{{ post.tags[0] }}', { post: { tags: ['one', 'two'] } })).toBe('/one') + expect(resolve('/{{ $doc.post.tags[1] }}', { $doc: { post: { tags: ['one', 'two'] } } })).toBe('/two') + }) +}) + +describe('stringify', () => { + it('converts numbers', () => { + expect(resolve('/{{ n }}', { n: 0 })).toBe('/0') + expect(resolve('/{{ n }}', { n: 1.5 })).toBe('/1.5') + expect(resolve('/{{ n }}', { n: -3 })).toBe('/-3') + }) + + it('converts booleans', () => { + expect(resolve('/{{ b }}', { b: true })).toBe('/true') + expect(resolve('/{{ b }}', { b: false })).toBe('/false') + }) + + it.each([ + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['-Infinity', Number.NEGATIVE_INFINITY], + ['null', null], + ['undefined', undefined], + ['object', { a: 1 }], + ['array', [1, 2]], + ['function', () => 'x'], + ['Date', new Date(0)], + ['symbol', Symbol('s')], + ['bigint', BigInt(1)], + ])('treats %s as unresolved', (_name, value) => { + expect(resolve('/{{ v }}', { v: value })).toBe('/') + expect(resolve('/{{ v || \'d\' }}', { v: value })).toBe('/d') + }) +}) + +describe('resolver security', () => { + it('blocks prototype segments', () => { + expect(resolve('/{{ __proto__.x }}', {})).toBe('/') + expect(resolve('/{{ constructor.name }}', {})).toBe('/') + expect(resolve('/{{ a.constructor.constructor }}', { a: {} })).toBe('/') + expect(resolve('/{{ a.prototype }}', { a: {} })).toBe('/') + expect(resolve('/{{ a.__proto__.polluted }}', { a: {} })).toBe('/') + expect(({} as Record).polluted).toBeUndefined() + }) + + it('blocks a blocked segment even when it is an own property', () => { + const own = JSON.parse('{"__proto__": {"x": "leak"}, "constructor": "c", "prototype": "p"}') + expect(resolve('/{{ a.__proto__.x }}', { a: own })).toBe('/') + expect(resolve('/{{ a.constructor }}', { a: own })).toBe('/') + expect(resolve('/{{ a.prototype }}', { a: own })).toBe('/') + }) + + it('ignores inherited properties', () => { + const proto = { inherited: 'leak' } + const child = Object.create(proto) as Record + expect(resolve('/{{ toString }}', {})).toBe('/') + expect(resolve('/{{ a.toString }}', { a: {} })).toBe('/') + expect(resolve('/{{ a.inherited }}', { a: child })).toBe('/') + expect(resolve('/{{ a.hasOwnProperty }}', { a: {} })).toBe('/') + }) + + it('never calls functions', () => { + const fn = vi.fn(() => 'called') + expect(resolve('/{{ fn }}', { fn })).toBe('/') + expect(resolve('/{{ a.fn }}', { a: { fn } })).toBe('/') + expect(resolve('/{{ a.fn.name }}', { a: { fn } })).toBe('/') + expect(fn).not.toHaveBeenCalled() + }) + + it('does not read through string midpoints', () => { + expect(resolve('/{{ a.length }}', { a: 'abc' })).toBe('/') + expect(resolve('/{{ a[0] }}', { a: 'abc' })).toBe('/') + }) + + it.each([ + '$route.query.x', + '$route', + 'runtimeData.x', + 'updateRuntimeData', + 'a.runtimeData', + '$doc.$route.x', + '$doc.runtimeData.x', + '$document.updateRuntimeData', + '$doc.tags.X', + '$document.tags.X', + 'tags.X', + 'tags[0]', + ])('blocks %s', (path) => { + // Root `tags` must be a map, so a missing block rule leaks the value. + const tags = { X: 'secret', 0: 'secret' } + const context = { + $route: { query: { x: 'secret' } }, + runtimeData: { x: 'secret' }, + updateRuntimeData: 'secret', + tags, + a: { runtimeData: 'secret' }, + $doc: { $route: { x: 'secret' }, runtimeData: { x: 'secret' }, tags }, + $document: { updateRuntimeData: 'secret', tags }, + } + expect(resolve(`/{{ ${path} }}`, context)).toBe('/') + }) + + it('does not block tags deeper in the path', () => { + expect(resolve('/{{ $doc.post.tags[0] }}', { $doc: { post: { tags: ['t'] } } })).toBe('/t') + expect(resolve('/{{ a.b.tags }}', { a: { b: { tags: 'ok' } } })).toBe('/ok') + }) + + it('uses the default for blocked paths', () => { + expect(resolve('/{{ $route.query.x || \'d\' }}', { $route: { query: { x: 'secret' } } })).toBe('/d') + }) +}) + +describe('no re-evaluation', () => { + it('keeps template syntax in a data value literal', () => { + expect(resolve('x/{{ a }}', { a: '{{ b }}', b: 'LEAK' })).toBe('x/{{ b }}') + }) + + it('keeps template syntax in a default literal', () => { + expect(resolve('x/{{ a || \'{{ b\' }}', { b: 'LEAK' })).toBe('x/{{ b') + expect(resolve('x/{{ a || \'{ b }\' }}', { b: 'LEAK' })).toBe('x/{ b }') + }) +}) + +describe('malformed templates', () => { + it.each([ + ['unterminated binding', '{{ x'], + ['single closing brace', '{{ x }'], + ['triple braces', '{{{ x }}}'], + ['two identifiers', '{{ a b }}'], + ['unknown filter raw', '{{ x | raw }}'], + ['unknown filter encode', '{{ x | encode }}'], + ['empty body', '{{ }}'], + ['no body', '{{}}'], + ['trailing dot', '{{ a.b. }}'], + ['leading digit', '{{ 1a }}'], + ['call syntax', '{{ a() }}'], + ['expression', '{{ a + b }}'], + ['default with closing braces', '{{ a || \'x}}y\' }}'], + ['unquoted default', '{{ a || d }}'], + ['unterminated default', '{{ a || \'d }}'], + ['second binding malformed', '/{{ a }}/{{ b c }}'], + ['non-numeric index', '{{ a[b] }}'], + ['bracket string index', '{{ a["b"] }}'], + ])('drops %s', (_name, template) => { + const secret = 'SECRETVALUE' + expect(resolve(template, { a: secret, x: secret }, 'title')).toBeUndefined() + expect(warn).toHaveBeenCalledTimes(1) + const message = warn.mock.calls[0]!.join(' ') + expect(message).toBe(`${WARN_PREFIX}title`) + expect(message).not.toContain(secret) + expect(message).not.toContain('{{') + }) +}) + +describe('caps', () => { + it('drops a template over the length cap', () => { + const template = `{{ a }}${'x'.repeat(MAX_TEMPLATE_LENGTH + 1 - 7)}` + expect(template).toHaveLength(MAX_TEMPLATE_LENGTH + 1) + expect(resolve(template, { a: 'v' })).toBeUndefined() + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('allows a template of exactly the length cap', () => { + const template = `{{a}}${'x'.repeat(MAX_TEMPLATE_LENGTH - 5)}` + expect(template).toHaveLength(MAX_TEMPLATE_LENGTH) + const output = resolve(template, { a: '' }) + expect(output).toHaveLength(MAX_TEMPLATE_LENGTH - 5) + expect(warn).not.toHaveBeenCalled() + }) + + it('drops more bindings than the cap', () => { + expect(resolve('{{a}}'.repeat(MAX_BINDINGS + 1), { a: 'v' })).toBeUndefined() + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('allows exactly the maximum number of bindings', () => { + expect(resolve('{{a}}'.repeat(MAX_BINDINGS), { a: 'v' })).toBe('v'.repeat(MAX_BINDINGS)) + }) + + it('drops a binding body over the cap', () => { + const body = 'a'.repeat(MAX_BINDING_LENGTH + 1) + expect(resolve(`{{ ${body} }}`, { [body]: 'v' })).toBeUndefined() + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('allows a binding body at the cap', () => { + const body = 'a'.repeat(MAX_BINDING_LENGTH) + expect(resolve(`/{{${body}}}`, { [body]: 'v' })).toBe('/v') + }) + + it('drops an output over the length cap', () => { + expect(resolve('/{{ a }}', { a: 'x'.repeat(MAX_TEMPLATE_LENGTH + 10) })).toBeUndefined() + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('allows an output at the length cap', () => { + const output = resolve('/{{ a }}', { a: 'x'.repeat(MAX_TEMPLATE_LENGTH - 1) }) + expect(output).toHaveLength(MAX_TEMPLATE_LENGTH) + }) +}) + +describe('control characters', () => { + it.each([ + ['tab', 'java\tscript:alert(1)'], + ['newline', 'java\nscript:alert(1)'], + ['carriage return', 'java\rscript:alert(1)'], + ['nul', 'java\u0000script:alert(1)'], + ['delete', 'java\u007Fscript:alert(1)'], + ['leading nul', '\u0000javascript:alert(1)'], + ])('drops a %s smuggled scheme', (_name, value) => { + expect(resolve('{{ u }}', { u: value }, 'to')).toBeUndefined() + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('strips control characters from a benign value', () => { + expect(resolve('/{{ a }}', { a: 'a\tb' })).toBe('/ab') + expect(resolve('/{{ a }}', { a: 'a\nb\u0000c\u007Fd\u001Fe' })).toBe('/abcde') + }) + + it('keeps non-control whitespace and unicode', () => { + expect(resolve('/{{ a }}', { a: 'a b é' })).toBe('/a b é') + }) +}) + +describe('final validation', () => { + it.each(['to', 'href', 'url', 'title'])('drops dangerous schemes for %s', (attribute) => { + for (const u of [ + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + ' javascript:alert(1)', + 'vbscript:x', + 'data:text/html,', + ]) { + expect(resolve('{{ u }}', { u }, attribute)).toBeUndefined() + } + expect(warn).toHaveBeenCalledTimes(5) + }) + + it('drops a scheme assembled from several bindings', () => { + expect(resolve('{{a}}{{b}}', { a: 'java', b: 'script:alert(1)' })).toBeUndefined() + }) + + it('drops a scheme assembled from literal text and a value', () => { + expect(resolve('javascript:{{a}}', { a: 'alert(1)' })).toBeUndefined() + }) + + it('drops percent encoded schemes', () => { + expect(resolve('{{ u }}', { u: '%6Aavascript:alert(1)' })).toBeUndefined() + }) + + it('drops malformed percent encoding without throwing', () => { + expect(() => resolve('{{ u }}', { u: '%' }, 'href')).not.toThrow() + expect(resolve('{{ u }}', { u: '%' }, 'href')).toBeUndefined() + expect(resolve('{{ u }}', { u: '%E0%A4%A' }, 'href')).toBeUndefined() + }) + + it.each(['onclick', 'srcdoc', 'onerror', '.onclick', 'formaction'])('drops the unsafe attribute %s', (attribute) => { + expect(resolve('{{ a }}', { a: 'harmless' }, attribute)).toBeUndefined() + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]!.join(' ')).toBe(`${WARN_PREFIX}${attribute}`) + }) + + it('does not throw on a lone surrogate', () => { + expect(() => resolve('{{ a }}', { a: '\uD800' })).not.toThrow() + }) +}) + +describe('valid values', () => { + it('returns safe values', () => { + expect(resolve('{{ u }}', { u: 'https://ok.example/a' })).toBe('https://ok.example/a') + expect(resolve('{{ u }}', { u: '/p?x=1' })).toBe('/p?x=1') + expect(resolve('mailto:{{e}}', { e: 'a@b.c' })).toBe('mailto:a@b.c') + expect(resolve('tel:{{n}}', { n: '+123' })).toBe('tel:+123') + expect(resolve('?q={{x}}', { x: 'v' })).toBe('?q=v') + expect(resolve('#{{x}}', { x: 'v' })).toBe('#v') + expect(warn).not.toHaveBeenCalled() + }) + + it('inserts values verbatim without encoding', () => { + expect(resolve('/s?q={{ x }}', { x: 'a b&c=d' })).toBe('/s?q=a b&c=d') + }) + + it('allows text with a colon in a title', () => { + expect(resolve('Note: {{x}}', { x: 'v' }, 'title')).toBe('Note: v') + expect(resolve('Version {{ v || \'1.0\' }}', {}, 'title')).toBe('Version 1.0') + expect(warn).not.toHaveBeenCalled() + }) +}) + +describe('warn behaviour', () => { + it('warns once with the attribute name only', () => { + expect(resolve('{{ u }}', { u: 'javascript:SECRETPAYLOAD' }, 'href')).toBeUndefined() + expect(warn).toHaveBeenCalledTimes(1) + const args = warn.mock.calls[0]! + expect(args).toHaveLength(1) + expect(args[0]).toBe(`${WARN_PREFIX}href`) + }) +}) + +describe('reactivity', () => { + it('tracks the key read so a watcher re-runs on change', () => { + const context = reactive>({ a: { id: 'one' } }) + const seen: Array = [] + const stop = watchEffect(() => { + seen.push(resolvePropTemplate('/x/{{ a.id }}', context, { attribute: 'to' })) + }, { flush: 'sync' }) + expect(seen).toEqual(['/x/one']) + ;(context.a as Record).id = 'two' + expect(seen).toEqual(['/x/one', '/x/two']) + context.a = { id: 'three' } + expect(seen).toEqual(['/x/one', '/x/two', '/x/three']) + stop() + }) + + it('tracks a key that does not exist yet', () => { + const context = reactive>({}) + const seen: Array = [] + const stop = watchEffect(() => { + seen.push(resolvePropTemplate('/x/{{ a }}', context, { attribute: 'to' })) + }, { flush: 'sync' }) + context.a = 'later' + expect(seen).toEqual(['/x/', '/x/later']) + stop() + }) +}) + +describe('performance', () => { + const cases: Array<[string, string]> = [ + ['spaces in an open binding', `{{${' '.repeat(4000)}`], + ['repeated open braces', '{{ '.repeat(1300)], + ['repeated single close braces', `{{${'} '.repeat(2000)}`], + ['over-cap input', `{{${' '.repeat(100000)}`], + ['long closed body of spaces', `{{${' '.repeat(3000)}}}`], + ['long default with pipes', `{{ a || '${'|'.repeat(200)}`], + ] + + it.each(cases)('drops %s quickly', (_name, template) => { + const start = performance.now() + const output = resolve(template, {}) + const elapsed = performance.now() - start + expect(output).toBeUndefined() + // A cubic pattern needs seconds here, so a loose limit still guards it. + expect(elapsed).toBeLessThan(500) + }) +}) + +describe('non-link props', () => { + it('keeps a percent sign in text props', () => { + expect(resolve('Save {{ n }}%', { n: 50 }, 'title')).toBe('Save 50%') + expect(resolve('{{ n }}%', { n: '100' }, 'label')).toBe('100%') + }) + + it('keeps text that is not a valid URL in text props', () => { + expect(resolve('See {{ u }}', { u: 'http://' }, 'title')).toBe('See http://') + expect(resolve('{{ u }}', { u: '//' }, 'title')).toBe('//') + }) + + it('still drops a malformed percent sequence in link props', () => { + expect(resolve('{{ n }}%', { n: '100' }, 'href')).toBeUndefined() + expect(resolve('{{ n }}%', { n: '100' }, 'src')).toBeUndefined() + }) + + it('still drops an unsafe scheme next to a malformed percent sequence', () => { + expect(resolve('{{ u }}%', { u: 'javascript:alert(1)' }, 'to')).toBeUndefined() + expect(resolve('{{ u }}%', { u: 'vbscript:x' }, 'title')).toBeUndefined() + }) +}) + +describe('hostile context', () => { + it('drops the prop when a getter throws', () => { + const context = { + x: { + get y(): string { + throw new Error('boom') + }, + }, + } + expect(() => resolve('/{{ x.y }}', context)).not.toThrow() + expect(resolve('/{{ x.y }}', context)).toBeUndefined() + expect(warn).toHaveBeenCalledWith(`${WARN_PREFIX}to`) + }) + + it('drops the prop when a proxy trap throws', () => { + const trap = new Proxy({}, { + has() { + throw new Error('boom') + }, + get() { + throw new Error('boom') + }, + getOwnPropertyDescriptor() { + throw new Error('boom') + }, + }) + expect(() => resolve('/{{ x.y }}', { x: trap })).not.toThrow() + expect(resolve('/{{ x.y }}', { x: trap })).toBeUndefined() + }) + + it('drops the prop when a proxy is revoked', () => { + const { proxy, revoke } = Proxy.revocable({ y: 'v' }, {}) + revoke() + expect(resolve('/{{ x.y }}', { x: proxy })).toBeUndefined() + }) + + it('does not run a getter on an inherited key', () => { + const getter = vi.fn(() => 'called') + const proto = Object.defineProperty({}, 'y', { get: getter }) + const child = Object.create(proto) as Record + expect(resolve('/{{ x.y }}', { x: child })).toBe('/') + expect(getter).not.toHaveBeenCalled() + }) + + it('drops early when inserted values exceed the cap', () => { + const big = 'a'.repeat(5_000_000) + const start = performance.now() + const template = Array.from({ length: MAX_BINDINGS }, () => '{{ a }}').join('') + expect(resolve(template, { a: big })).toBeUndefined() + expect(performance.now() - start).toBeLessThan(500) + }) +}) + +describe('script-capable data URLs', () => { + it.each([ + 'data: text/html,', + 'data: application/xhtml+xml,', + ])('drops %j in any prop', (value) => { + for (const attribute of ['src', 'to', 'title']) { + expect(resolve('{{ v }}', { v: value }, attribute)).toBeUndefined() + } + }) +}) + +describe('isInterpolatedProp keeps legacy values', () => { + it.each([ + '[{"label":"{{ name }}"}]', + '{"template":"Hello {{ name }}"}', + '["{{ a }}", "{{ b }}"]', + ' {"a": "{{ x }}"} ', + ])('skips JSON object and array values: %s', (value) => { + expect(isInterpolatedProp('items', value)).toBe(false) + }) + + it.each([ + '{{ a }}', + '{{ a }} and {{ b }}', + '[{{ a }}]', + '{{ a }}}', + '/x/{{ a }}', + ])('still interpolates invalid JSON: %s', (value) => { + expect(isInterpolatedProp('items', value)).toBe(true) + }) + + it.each([ + ['unterminated binding', 'Use {{ name'], + ['filter syntax', 'Use {{ name | upper }}'], + ['empty binding', 'Use {{ }}'], + ['expression', 'Use {{ a + b }}'], + ['trailing dot', '/x/{{ a.b. }}'], + ['default with close braces', '{{ a || \'x}}y\' }}'], + ['over the length cap', `{{ a }}${'x'.repeat(MAX_TEMPLATE_LENGTH)}`], + ['too many bindings', '{{ a }}'.repeat(MAX_BINDINGS + 1)], + ['body over the length cap', `{{ ${'a'.repeat(MAX_BINDING_LENGTH)} }}`], + ])('skips a malformed template (%s)', (_name, value) => { + expect(isInterpolatedProp('title', value)).toBe(false) + }) + + it('does not warn when it skips a value', () => { + isInterpolatedProp('title', 'Use {{ name | upper }}') + isInterpolatedProp('items', '["{{ a }}"]') + expect(warn).not.toHaveBeenCalled() + }) + + it('still drops a malformed template in resolvePropTemplate', () => { + expect(resolve('Use {{ name | upper }}', {}, 'title')).toBeUndefined() + }) +}) From a68872df8cb86115b154fe7d2b33ffc2466a10ad Mon Sep 17 00:00:00 2001 From: Adam DeHaven Date: Wed, 14 Oct 2026 18:15:14 -0400 Subject: [PATCH 2/2] fix: edge cases --- README.md | 1 + docs/0.index.md | 1 + src/runtime/parser/utils/props.ts | 6 +++- src/runtime/utils/template.ts | 43 ++++++++++++++++++----- test/utils/template.test.ts | 58 ++++++++++++++++++++++++++++--- 5 files changed, 94 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 64a72b46..1db09772 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ Open the API console A placeholder is a dotted path, with optional indexes (`items[0].name`) and an optional default (`|| 'text'`). It reads document data, ancestor props and slot props, but not sibling props. Use `$doc.key` when an ancestor prop hides a document key. - **Values:** strings, finite numbers and booleans are inserted as text. Anything else uses the default, or an empty string. +- **Whitespace:** line breaks and tabs in inserted values become one space. Other control characters are removed. - **No encoding:** values are not URL-encoded or escaped. Only insert data you trust, especially for props like `id` or `name`. - **Safe lookups:** placeholders read own properties only and never call functions. They cannot read `__proto__`, `constructor`, `prototype`, `$route`, `runtimeData`, `updateRuntimeData` or a root `tags`. - **Stale values:** like `{{ }}` in text, placeholders read the data passed to the renderer. A value changed only through `v-model` can look stale. diff --git a/docs/0.index.md b/docs/0.index.md index b0b52367..d30a8430 100644 --- a/docs/0.index.md +++ b/docs/0.index.md @@ -142,6 +142,7 @@ Open the API console A placeholder is a dotted path, with optional indexes (`items[0].name`) and an optional default (`|| 'text'`). It reads document data, ancestor props and slot props, but not sibling props. Use `$doc.key` when an ancestor prop hides a document key. - **Values:** strings, finite numbers and booleans are inserted as text. Anything else uses the default, or an empty string. +- **Whitespace:** line breaks and tabs in inserted values become one space. Other control characters are removed. - **No encoding:** values are not URL-encoded or escaped. Only insert data you trust, especially for props like `id` or `name`. - **Safe lookups:** placeholders read own properties only and never call functions. They cannot read `__proto__`, `constructor`, `prototype`, `$route`, `runtimeData`, `updateRuntimeData` or a root `tags`. - **Stale values:** like `{{ }}` in text, placeholders read the data passed to the renderer. A value changed only through `v-model` can look stale. diff --git a/src/runtime/parser/utils/props.ts b/src/runtime/parser/utils/props.ts index 1ad753cf..4f5fdfba 100644 --- a/src/runtime/parser/utils/props.ts +++ b/src/runtime/parser/utils/props.ts @@ -22,7 +22,10 @@ export const unsafeLinkPrefix = [ 'data:application/xml', ] -/** Options for `isAnchorLinkAllowed`. */ +/** + * Options for `isAnchorLinkAllowed`. + * @internal + */ export interface AnchorLinkOptions { /** Accept values that are not decodable or not a valid URL. Only unsafe schemes fail. Use it for props that are not links. */ lenient?: boolean @@ -33,6 +36,7 @@ export interface AnchorLinkOptions { * * @param value - Link value to check. * @param options - Check options. + * @internal */ export function isAnchorLinkAllowed(value: string, options: AnchorLinkOptions = {}) { const { lenient = false } = options diff --git a/src/runtime/utils/template.ts b/src/runtime/utils/template.ts index 17d1bdbf..4f9a3ebf 100644 --- a/src/runtime/utils/template.ts +++ b/src/runtime/utils/template.ts @@ -1,15 +1,27 @@ import { isAnchorLinkAllowed, validateProp } from '../parser/utils/props' -/** Maximum length of a template and of the assembled output. */ +/** + * Maximum length of a template and of the assembled output. + * @internal + */ export const MAX_TEMPLATE_LENGTH = 4096 -/** Maximum number of `{{ }}` bindings in one template. */ +/** + * Maximum number of `{{ }}` bindings in one template. + * @internal + */ export const MAX_BINDINGS = 16 -/** Maximum length of the text between `{{` and `}}`. */ +/** + * Maximum length of the text between `{{` and `}}`. + * @internal + */ export const MAX_BINDING_LENGTH = 256 -/** Options for `resolvePropTemplate`. */ +/** + * Options for `resolvePropTemplate`. + * @internal + */ export interface ResolvePropTemplateOptions { /** Prop name without the `:` or `v-bind:` prefix. Used for validation and warnings. */ attribute: string @@ -18,18 +30,22 @@ export interface ResolvePropTemplateOptions { const RESERVED_ATTRIBUTES = ['key', 'ref', 'is', 'style'] const BLOCKED_SEGMENTS = ['__proto__', 'constructor', 'prototype', '$route', 'runtimeData', 'updateRuntimeData'] -/** One anchored pattern with no nested quantifiers, so matching is linear. */ -const BINDING_PATTERN = /^([A-Z_$][\w$-]*(?:\.[A-Z_$][\w$-]*|\[\d+\])*)(?:[ \t]*\|\|[ \t]*(?:'([^']*)'|"([^"]*)"))?$/i +/** + * One anchored pattern with no nested quantifiers, so matching is linear. + * The first segment cannot start with a digit. Later segments can, so `items.0` works. + */ +const BINDING_PATTERN = /^([\p{L}_$][\p{L}\p{N}_$-]*(?:\.[\p{L}\p{N}_$][\p{L}\p{N}_$-]*|\[\d+\])*)(?:[ \t]*\|\|[ \t]*(?:'([^']*)'|"([^"]*)"))?$/u -/** Control characters allow scheme smuggling such as `java\tscript:`. */ // eslint-disable-next-line no-control-regex const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/g +const LINE_BREAKS = /[\t\r\n]+/g /** * Check if a value is a string with a `{{` followed later by `}}`. * Runs in linear time. * * @param value - Value to check. + * @internal */ export function hasTemplate(value: unknown): boolean { if (typeof value !== 'string') { @@ -122,6 +138,7 @@ function parseTemplate(template: string): TemplatePart[] | undefined { * * @param attribute - Prop name without the `:` or `v-bind:` prefix. * @param value - Prop value. + * @internal */ export function isInterpolatedProp(attribute: string, value: unknown): boolean { const name = attribute.toLowerCase().replace(/^[.^]/, '') @@ -184,6 +201,7 @@ function drop(attribute: string): undefined { * @param context - Data used to resolve paths. The function does not change it. * @param options - Resolve options. * @returns The assembled string, or `undefined` when the prop must be dropped. + * @internal */ export function resolvePropTemplate( template: string, @@ -200,13 +218,18 @@ export function resolvePropTemplate( // Context data is not trusted. A getter or proxy trap can throw. try { let output = '' + // Browsers ignore line breaks and tabs in a URL, so `java\tscript:` is a script scheme. + // Check a second copy without any control character. + let compact = '' for (const part of parts) { if (typeof part === 'string') { output += part + compact += part continue } const value = resolvePath(part.path, context) ?? part.fallback - output += value.replace(CONTROL_CHARACTERS, '') + output += value.replace(LINE_BREAKS, ' ').replace(CONTROL_CHARACTERS, '') + compact += value.replace(CONTROL_CHARACTERS, '') // Stop early, so a huge value cannot grow the output. if (output.length > MAX_TEMPLATE_LENGTH) { return drop(attribute) @@ -214,7 +237,9 @@ export function resolvePropTemplate( } // `validateProp` stays strict for link props. The scheme check is lenient so text such as `50%` survives. - if (!validateProp(attribute, output) || !isAnchorLinkAllowed(output, { lenient: true })) { + const safe = [output, compact].every(value => + validateProp(attribute, value) && isAnchorLinkAllowed(value, { lenient: true })) + if (!safe) { return drop(attribute) } return output diff --git a/test/utils/template.test.ts b/test/utils/template.test.ts index 522e8cee..0c3a7de6 100644 --- a/test/utils/template.test.ts +++ b/test/utils/template.test.ts @@ -134,8 +134,9 @@ describe('defaults', () => { expect(resolve('/{{a||\'d\'}}', {})).toBe('/d') }) - it('strips control characters from a default', () => { - expect(resolve('/{{ a || \'x\ty\' }}', {})).toBe('/xy') + it('cleans control characters in a default like a value', () => { + expect(resolve('/{{ a || \'x\ty\' }}', {})).toBe('/x y') + expect(resolve('/{{ a || \'x\u0000y\' }}', {})).toBe('/xy') }) }) @@ -370,9 +371,26 @@ describe('control characters', () => { expect(warn).toHaveBeenCalledTimes(1) }) - it('strips control characters from a benign value', () => { - expect(resolve('/{{ a }}', { a: 'a\tb' })).toBe('/ab') - expect(resolve('/{{ a }}', { a: 'a\nb\u0000c\u007Fd\u001Fe' })).toBe('/abcde') + it('turns line breaks and tabs into one space', () => { + expect(resolve('{{ d }}', { d: 'Line one\nLine two' }, 'title')).toBe('Line one Line two') + expect(resolve('{{ d }}', { d: 'one\r\ntwo' }, 'aria-label')).toBe('one two') + expect(resolve('/{{ a }}', { a: 'a\tb' })).toBe('/a b') + expect(resolve('{{ x || \'a\nb\' }}', {}, 'title')).toBe('a b') + }) + + it('strips other control characters from a benign value', () => { + expect(resolve('/{{ a }}', { a: 'a\u0000b\u007Fc\u001Fd' })).toBe('/abcd') + expect(resolve('/{{ a }}', { a: 'a\nb\u0000c' })).toBe('/a bc') + }) + + it.each([ + '\njavascript:alert(1)', + 'java\n\tscript:alert(1)', + 'java\r\nscript:alert(1)', + 'vb\tscript:x', + ])('still drops a scheme hidden behind line breaks: %j', (value) => { + expect(resolve('{{ u }}', { u: value }, 'to')).toBeUndefined() + expect(resolve('{{ u }}', { u: value }, 'href')).toBeUndefined() }) it('keeps non-control whitespace and unicode', () => { @@ -632,3 +650,33 @@ describe('isInterpolatedProp keeps legacy values', () => { expect(resolve('Use {{ name | upper }}', {}, 'title')).toBeUndefined() }) }) + +describe('path syntax', () => { + it.each([ + ['unicode letters', '/{{ café }}', { café: 'z' }, '/z'], + ['non-latin keys', '/{{ 日本.名前 }}', { 日本: { 名前: 'n' } }, '/n'], + ['numeric dot segment', '/{{ a.0 }}', { a: ['first'] }, '/first'], + ['numeric segment in the middle', '/{{ a.1.b }}', { a: [{}, { b: 'x' }] }, '/x'], + ['numeric segment under $doc', '/{{ $doc.items.1 }}', { $doc: { items: ['a', 'b'] } }, '/b'], + ])('resolves %s', (_name, template, context, expected) => { + expect(resolve(template, context)).toBe(expected) + }) + + it.each([ + ['numeric first segment', '{{ 1 }}'], + ['leading dot', '{{ .a }}'], + ['double dot', '{{ a..b }}'], + ['trailing dot', '{{ a. }}'], + ['bracket text index', '{{ a[b] }}'], + ['space in a path', '{{ a b }}'], + ])('rejects %s', (_name, template) => { + expect(resolve(template, { a: ['x'] })).toBeUndefined() + }) + + it('stays linear on long dotted paths', () => { + const start = performance.now() + expect(resolve(`{{ ${'a.'.repeat(120)}! }}`, {})).toBeUndefined() + expect(resolve(`{{ ${'é'.repeat(250)}! }}`, {})).toBeUndefined() + expect(performance.now() - start).toBeLessThan(500) + }) +})