From 9b1a15a3cb36453db8c16335d15efb0cdade3657 Mon Sep 17 00:00:00 2001 From: Adam DeHaven Date: Thu, 15 Oct 2026 19:33:47 -0400 Subject: [PATCH 1/4] feat(props): interpolate placeholders in bound props Resolve placeholders such as path or default in colon-prefixed string props at render time. They read the existing render context: frontmatter, meta, data and props. The result is always a string, and no plugin is needed. Only text, numbers and booleans are inserted. A missing value uses the default. A prop that is a single placeholder with nothing to show is omitted. Malformed placeholders, unknown roots, JSON values and the key, ref, is, as and style props keep their current behavior. The final text is checked for unsafe URL schemes and unsafe prop names with the existing validation. Also fix a scope leak. An element whose props were all omitted inherited its parent props in the HTML renderer, and its grandparent props in the Vue, React, Svelte and Angular child scope. Ported from the nuxtjs/mdc implementation: https://github.com/nuxt-content/mdc/pull/516 Closes #489 --- docs/app/constants/index.ts | 27 +++ docs/content/2.syntax/2.components.md | 37 +++ docs/content/4.plugins/1.built-in/binding.md | 2 + docs/content/4.plugins/1.built-in/security.md | 6 + .../3.plugins/vue-vite-binding/src/App.vue | 18 +- .../src/components/markdown-node.component.ts | 19 +- .../comark-angular/test/props-scope.test.ts | 69 ++++++ packages/comark-ansi/test/index.test.ts | 15 ++ .../comark-html/test/plugin-binding.test.ts | 8 + packages/comark-html/test/props-scope.test.ts | 33 +++ packages/comark-html/test/string.test.ts | 27 +++ .../src/components/MarkdownDocument.tsx | 11 +- .../comark-react/test/props-scope.test.tsx | 46 ++++ .../test/renderer-data-binding.test.tsx | 22 ++ .../src/components/MarkdownNode.svelte | 13 +- .../comark-svelte/test/plugin-binding.test.ts | 18 ++ .../comark-svelte/test/props-scope.test.ts | 34 +++ .../test/test-components/Probe.svelte | 5 + .../src/components/MarkdownDocument.ts | 11 +- .../test/interpolation-reactivity.test.ts | 73 ++++++ packages/comark-vue/test/props-scope.test.ts | 46 ++++ .../test/renderer-data-binding.test.ts | 36 +++ .../SPEC/COMARK/data-binding-interpolation.md | 69 ++++++ .../src/internal/stringify/attributes.ts | 16 ++ .../src/internal/stringify/interpolate.ts | 107 +++++++++ .../comark/src/internal/stringify/state.ts | 18 +- packages/comark/test/dos.test.ts | 67 ++++++ packages/comark/test/interpolate.test.ts | 219 ++++++++++++++++++ .../comark/test/resolve-attributes.test.ts | 135 +++++++++++ 29 files changed, 1193 insertions(+), 14 deletions(-) create mode 100644 packages/comark-angular/test/props-scope.test.ts create mode 100644 packages/comark-html/test/props-scope.test.ts create mode 100644 packages/comark-react/test/props-scope.test.tsx create mode 100644 packages/comark-svelte/test/props-scope.test.ts create mode 100644 packages/comark-svelte/test/test-components/Probe.svelte create mode 100644 packages/comark-vue/test/interpolation-reactivity.test.ts create mode 100644 packages/comark-vue/test/props-scope.test.ts create mode 100644 packages/comark/SPEC/COMARK/data-binding-interpolation.md create mode 100644 packages/comark/src/internal/stringify/interpolate.ts create mode 100644 packages/comark/test/interpolate.test.ts diff --git a/docs/app/constants/index.ts b/docs/app/constants/index.ts index 736a74bc..ef45c227 100644 --- a/docs/app/constants/index.ts +++ b/docs/app/constants/index.ts @@ -435,6 +435,28 @@ Free for up to 5 services. No credit card. Deploys in under a minute. :: ` +export const dynamicPropsMarkdown = `--- +title: Dynamic props +description: Build prop values from fixed text and frontmatter +page: + "#comment": The "page" field is custom field and exists only for demonstration, it is no part of Comark core + maxWidth: 1120px +repo: comarkdown/comark +version: 0.7.0 +author: Ada +--- + +# {{ frontmatter.title }} + +Props that start with a colon can mix fixed text with placeholders. Edit the frontmatter and watch the preview. + +:::Button{:to="https://github.com/{{ frontmatter.repo }}" :label="Open {{ frontmatter.repo }} on GitHub" icon="i-lucide-github" target="_blank"} +::: + +::Alert{:title="Version {{ frontmatter.version }}" :description="Maintained by {{ frontmatter.author || the team }}" icon="i-lucide-info" color="secondary"} +:: +` + export type AiMode = 'showcase' | 'nuxt-ui' export type PlaygroundExample = { @@ -474,6 +496,11 @@ export const playgroundExamples: PlaygroundExample[] = [ mode: 'nuxt-ui', prompt: 'Landing page for a mountain bike rental in the Alps.', }, + { + label: 'Dynamic props', + value: 'dynamic-props', + content: dynamicPropsMarkdown, + }, ] export const defaultMarkdown = bookingMarkdown diff --git a/docs/content/2.syntax/2.components.md b/docs/content/2.syntax/2.components.md index 1d9abd5a..e3a05483 100644 --- a/docs/content/2.syntax/2.components.md +++ b/docs/content/2.syntax/2.components.md @@ -314,6 +314,43 @@ Nested components can read the enclosing component's resolved props through the Need to interpolate a value directly into text rather than a prop? The [Binding plugin](/plugins/built-in/binding) adds a `{{ path || default }}` inline shorthand that resolves against the same render context. :: +### String interpolation + +Mix fixed text with data in a prop by wrapping a path in `{{ }}`. The renderer resolves each placeholder against the same render context and passes the result as a string: + +```mdc +:a[Read more]{:href="/docs/{{ data.slug }}/overview"} +``` + +With `data` set to `{ slug: 'getting-started' }`, the link renders with `href="/docs/getting-started/overview"`. It works in YAML block props too: + +```mdc +::card +--- +:title: Item {{ frontmatter.id }} +--- +:: +``` + +Add a fallback with `||`. Like text bindings, the default is plain text, so quotes are kept: + +```mdc +:badge{:label="Hello {{ data.user.name || friend }}"} +``` + +#### Key rules + +- Paths use the same namespaces as above. No plugin is needed. +- The result is always a string. Use the path form (`:count="data.n"`, `:value="data.off"`) for numbers and booleans, because `false` becomes the text `false`, which is truthy. +- A missing value uses the default, or an empty string. If the whole prop is one placeholder with nothing to show, the prop is omitted. +- Values are inserted as is, without URL encoding. Unsafe URL schemes such as `javascript:` and unsafe prop names (`on*`, `srcdoc`) omit the prop. +- Quote inline attribute values, and quote YAML values that start with `{{`. +- Props without the `:` prefix, the `key`, `ref`, `is`, `as` and `style` props, malformed placeholders and JSON values are left as written. + +::callout{color="warning" icon="i-lucide-triangle-alert"} +A `:` prop that already contained `{{ }}` text now interpolates. To keep literal braces, use a prop without the `:` prefix. +:: + ## Slots Block components support slots for passing structured content to components. diff --git a/docs/content/4.plugins/1.built-in/binding.md b/docs/content/4.plugins/1.built-in/binding.md index 5498f7fd..805a34a9 100644 --- a/docs/content/4.plugins/1.built-in/binding.md +++ b/docs/content/4.plugins/1.built-in/binding.md @@ -282,6 +282,8 @@ A binding value (`{{ path }}`) is resolved as a dot-path against the same render See [Data Binding](/syntax/components#data-binding) for the full contract and additional examples. +The same render context also resolves `{{ path || default }}` inside `:` props, such as `:a[Docs]{:href="/docs/{{ data.slug }}"}`. That feature needs no plugin, because the `binding()` plugin only handles `{{ }}` in text. See [String interpolation](/syntax/components#string-interpolation). + ## Default values Use `|| default` to specify a fallback that's emitted when the dot-path does not resolve: diff --git a/docs/content/4.plugins/1.built-in/security.md b/docs/content/4.plugins/1.built-in/security.md index 33c9cb9f..a266632f 100644 --- a/docs/content/4.plugins/1.built-in/security.md +++ b/docs/content/4.plugins/1.built-in/security.md @@ -137,6 +137,12 @@ The same check applies to `:href` and `:src` bindings twice: on the JSON-decoded :: +### Interpolated props + +A `:` prop can contain `{{ path }}` placeholders (see [String interpolation](/syntax/components#string-interpolation)). Comark checks the final string after it inserts the values, even without the `security()` plugin. Unsafe prop names and unsafe URL schemes are omitted. Path bindings are not checked this way: they only get the render-time URL check for `href`, `src`, `xlink:href` and `to`. + +`allowedLinkPrefixes`, `allowedProtocols` and `defaultOrigin` check literal values at parse time. They do not apply to the final interpolated URL, so avoid building URLs from untrusted data. + --- ## API diff --git a/examples/3.plugins/vue-vite-binding/src/App.vue b/examples/3.plugins/vue-vite-binding/src/App.vue index 1436f2dd..5228a7c1 100644 --- a/examples/3.plugins/vue-vite-binding/src/App.vue +++ b/examples/3.plugins/vue-vite-binding/src/App.vue @@ -12,7 +12,7 @@ const previewPlugins = [binding()] // Runtime data exposed to bindings via the `data.` namespace. const initialData = { user: { - name: 'Ada', + name: 'Adam', role: 'admin', age: 28, }, @@ -87,6 +87,22 @@ I am NOT fine and NOT happy ๐Ÿ˜ฉ ::card{title="Component props"} Inside the card the binding below pulls the card's title via \`props\`: +{{ props.title }} +:: + +## Dynamic props + +Build a prop from fixed text and data. Edit the inputs to update it. + +- :a[Open {{ data.user.name || friend }}'s profile]{:href="/users/{{ data.user.name }}/overview"} +- :a[Release notes]{:href="/releases/{{ frontmatter.release.version }}"} + +::card +--- +:title: Release {{ frontmatter.release.codename }} (v{{ frontmatter.release.version }}) +--- +The title above is built from the frontmatter: + {{ props.title }} :: ` diff --git a/packages/comark-angular/src/components/markdown-node.component.ts b/packages/comark-angular/src/components/markdown-node.component.ts index 50cc7de8..f6c38118 100644 --- a/packages/comark-angular/src/components/markdown-node.component.ts +++ b/packages/comark-angular/src/components/markdown-node.component.ts @@ -138,8 +138,15 @@ export class MarkdownNode implements OnChanges { // Resolve attributes (:binding support) const resolved = resolveAttributes(nodeProps, this.renderData, { parseJson: true }) - // Build childrenRenderData - only shadow parent scope when element has own attrs - const hasOwnAttrs = Object.keys(resolved).length > 0 + // Build childrenRenderData - only shadow parent scope when element has own attrs. + // The check uses the raw attributes: resolving can omit all of them. + let hasOwnAttrs = false + for (const key in nodeProps) { + if (key !== '$') { + hasOwnAttrs = true + break + } + } const childrenRenderData: NodeRenderData = hasOwnAttrs ? { ...this.renderData, props: resolved } : this.renderData if ((customComponent as StructuralComponent | undefined)?.ษตcomarkIf) { @@ -373,7 +380,13 @@ export class MarkdownNode implements OnChanges { } } else { const resolved = resolveAttributes(childProps, renderData, { parseJson: true }) - const hasOwnAttrs = Object.keys(resolved).length > 0 + let hasOwnAttrs = false + for (const key in childProps) { + if (key !== '$') { + hasOwnAttrs = true + break + } + } const childRenderData: NodeRenderData = hasOwnAttrs ? { ...renderData, props: resolved } : renderData this.renderNativeEl(parentEl, childTag, resolved, grandChildren, childRenderData, child) } diff --git a/packages/comark-angular/test/props-scope.test.ts b/packages/comark-angular/test/props-scope.test.ts new file mode 100644 index 00000000..cc03b2c5 --- /dev/null +++ b/packages/comark-angular/test/props-scope.test.ts @@ -0,0 +1,69 @@ +import '@angular/compiler' +import { describe, expect, it } from 'vitest' +import { Component, provideZonelessChangeDetection, type Type } from '@angular/core' +import { bootstrapApplication } from '@angular/platform-browser' +import { renderApplication } from '@angular/platform-server' +import { parseMarkdown, type MarkdownDocument as MarkdownDocumentType } from 'comark' +import { MarkdownDocument } from '../src/components/markdown-document.component.ts' + +class Probe { + text = '' +} +Component({ + selector: 'app-probe', + standalone: true, + inputs: ['text'], + template: '[{{ text }}]', +})(Probe) + +async function renderMarkdown(markdown: string): Promise { + const document = await parseMarkdown(markdown) + const components: Record> = { probe: Probe as Type } + + class App { + document: MarkdownDocumentType = document + components = components + data = {} + } + Component({ + selector: 'app-root', + standalone: true, + imports: [MarkdownDocument], + template: ` + + `, + })(App) + + return renderApplication( + (context) => bootstrapApplication(App, { providers: [provideZonelessChangeDetection()] }, context), + { document: '' } + ) +} + +describe('MarkdownDocument: props scope when every attribute is omitted', () => { + it('hides the grandparent props from children of an element with all attributes omitted', async () => { + const html = await renderMarkdown( + '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}"}\n::probe{:text="props.label"}\n::\n:::\n::::' + ) + expect(html).toContain('class="probe"') + expect(html).not.toContain('[Outer]') + }) + + it('exposes the resolved attributes of the middle element to its children', async () => { + const html = await renderMarkdown( + '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}" id="keep"}\n::probe{:text="props.id"}\n::\n:::\n::::' + ) + expect(html).toContain('[keep]') + expect(html).not.toContain('[Outer]') + }) + + it('keeps the parent props visible through bare wrappers', async () => { + const html = await renderMarkdown('::::wrap{label="Outer"}\n- :probe{:text="props.label"}\n::::') + expect(html).toContain('
    ') + expect(html).toContain('[Outer]') + }) +}) diff --git a/packages/comark-ansi/test/index.test.ts b/packages/comark-ansi/test/index.test.ts index 0439a89d..37b6d16b 100644 --- a/packages/comark-ansi/test/index.test.ts +++ b/packages/comark-ansi/test/index.test.ts @@ -404,6 +404,21 @@ home: https://example.com expect(out).toContain('[image: Photo of Ada]') }) + it('interpolates placeholders in :href and :alt', async () => { + const link = await parseMarkdown('[Doc](x){:href="/d/{{ data.id }}"}') + expect(await renderAnsiFromDocument(link, { colors: false, data: { id: 7 } })).toContain('Doc (/d/7)') + const img = await parseMarkdown('![x](/x.png){:alt="Alt {{ data.id }}"}') + expect(await renderAnsiFromDocument(img, { colors: false, data: { id: 7 } })).toContain('[image: Alt 7]') + }) + + it('prints the literal fallback when an interpolated URL is unsafe', async () => { + const link = await parseMarkdown('[Doc](x){:href="{{ data.bad }}"}') + const out = await renderAnsiFromDocument(link, { colors: false, data: { bad: 'javascript:alert(1)' } }) + expect(out).toBe('Doc (x)\n') + expect(out).not.toContain('javascript:') + expect(await renderAnsiFromDocument(link, { colors: false, data: { bad: '/ok' } })).toContain('Doc (/ok)') + }) + it('exposes parent props for custom handlers via resolveAttribute', async () => { const { resolveAttribute } = await import('comark/render') const tree = await parseMarkdown(`--- diff --git a/packages/comark-html/test/plugin-binding.test.ts b/packages/comark-html/test/plugin-binding.test.ts index 9278cc8a..dc1fea9f 100644 --- a/packages/comark-html/test/plugin-binding.test.ts +++ b/packages/comark-html/test/plugin-binding.test.ts @@ -98,6 +98,14 @@ describe('@comark/html plugins/binding โ€” If handler', () => { expect(hidden).toBe('') }) + it('interpolates `{{ }}` in If values to a truthy string even for false', async () => { + const data = { off: false } + const interpolated = await parseMarkdown('::if{:value="{{ data.off }}"}\nShown\n::') + expect(await renderHtmlFromDocument(interpolated, { components: { If }, data })).toBe('Shown') + const plain = await parseMarkdown('::if{:value="data.off"}\nShown\n::') + expect(await renderHtmlFromDocument(plain, { components: { If }, data })).toBe('') + }) + it('exposes normalized If props to bindings in its children', async () => { const doc = await parseWithBinding('::if{:value="true" :enabled="false"}\nEnabled {{ props.enabled }}\n::') const html = await renderHtmlFromDocument(doc, { components: { Binding, If } }) diff --git a/packages/comark-html/test/props-scope.test.ts b/packages/comark-html/test/props-scope.test.ts new file mode 100644 index 00000000..b3f74749 --- /dev/null +++ b/packages/comark-html/test/props-scope.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { parseMarkdown } from 'comark' +import { renderHtmlFromDocument } from '../src/index' + +async function render(markdown: string, data: Record = {}): Promise { + return renderHtmlFromDocument(await parseMarkdown(markdown), { data }) +} + +describe('props scope when every attribute is omitted', () => { + it('does not leak the parent props onto a child whose bindings all resolve to nothing', async () => { + const html = await render('::card{title="T" id="p"}\n:span[x]{:data-x="{{ data.nope }}"}\n::') + expect(html).toContain('') + expect(html).toContain('x') + }) + + it('does not leak the parent props onto an element whose unsafe URL is omitted', async () => { + const html = await render('::card{title="T"}\n:a[x]{:href="{{ data.bad }}"}\n::', { bad: 'javascript:alert(1)' }) + expect(html).not.toContain('javascript:') + expect(html).toContain('x') + }) + + it('keeps the resolved attributes and still hides the parent props', async () => { + const html = await render('::card{title="T"}\n:span[x]{:data-x="{{ data.nope }}" id="keep"}\n::') + expect(html).toContain('x') + }) + + it('keeps the parent props visible through bare wrappers', async () => { + const html = await render('::card{title="T"}\n- :span[x]{:data-x="props.title"}\n::') + expect(html).toContain('
      ') + expect(html).toContain('data-x="T"') + expect(html).not.toMatch(/<(ul|li)[^>]* title=/) + }) +}) diff --git a/packages/comark-html/test/string.test.ts b/packages/comark-html/test/string.test.ts index fe0ba0bb..969387e5 100644 --- a/packages/comark-html/test/string.test.ts +++ b/packages/comark-html/test/string.test.ts @@ -206,5 +206,32 @@ name: Ada const html = await renderHtmlFromDocument(doc) expect(html).toContain('title="frontmatter.name"') }) + + it('interpolates `{{ path }}` placeholders inside :prefixed props', async () => { + const doc = await parseMarkdown('Go: :a[Read more]{:href="/docs/{{ data.slug }}/overview"}') + const html = await renderHtmlFromDocument(doc, { data: { slug: 'getting-started' } }) + expect(html).toContain('') + }) + + it('interpolates placeholders in block YAML props', async () => { + const doc = await parseMarkdown('::card\n---\n:title: Item {{ data.id }}\n---\nBody\n::') + const html = await renderHtmlFromDocument(doc, { data: { id: 7 } }) + expect(html).toContain('title="Item 7"') + }) + + it('renders a link without href when the interpolated URL is unsafe', async () => { + const doc = await parseMarkdown(':a[x]{:href="{{ data.url }}"}') + const bad = await renderHtmlFromDocument(doc, { data: { url: 'javascript:alert(1)' } }) + expect(bad).toBe('x') + const ok = await renderHtmlFromDocument(doc, { data: { url: '/ok' } }) + expect(ok).toBe('x') + }) + + it('escapes interpolated values', async () => { + const doc = await parseMarkdown(':card{:title="T {{ data.t }}"}') + const html = await renderHtmlFromDocument(doc, { data: { t: 'a"b`, `
        `, `
      • `, โ€ฆ) must keep the - // parent's scope so bindings like `{{ props.x }}` reach across them. - const hasOwnAttrs = Object.keys(resolved).length > 0 + // parent's scope so bindings like `{{ props.x }}` reach across them. The + // check uses the raw attributes: resolving can omit all of them. + let hasOwnAttrs = false + for (const key in nodeProps) { + if (key !== '$') { + hasOwnAttrs = true + break + } + } const childrenRenderData: NodeRenderData = hasOwnAttrs ? { ...renderData, props: scopeProps } : renderData // Separate template elements (slots) from regular children const slots: Record = {} diff --git a/packages/comark-react/test/props-scope.test.tsx b/packages/comark-react/test/props-scope.test.tsx new file mode 100644 index 00000000..519a7114 --- /dev/null +++ b/packages/comark-react/test/props-scope.test.tsx @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import React from 'react' +import { renderToString } from 'react-dom/server' +import { parseMarkdown } from 'comark' +import { MarkdownDocument } from '../src/components/MarkdownDocument' + +function Probe({ text = '' }: { text?: string }) { + return {`[${text}]`} +} + +async function renderMarkdown(markdown: string, props: Record = {}) { + const tree = await parseMarkdown(markdown) + return renderToString( + + ) +} + +describe('MarkdownDocument: props scope when every attribute is omitted', () => { + it('hides the grandparent props from children of an element with all attributes omitted', async () => { + const html = await renderMarkdown( + '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}"}\n::probe{:text="props.label"}\n::\n:::\n::::', + { data: {} } + ) + expect(html).toContain('class="probe"') + expect(html).not.toContain('[Outer]') + }) + + it('exposes the resolved attributes of the middle element to its children', async () => { + const html = await renderMarkdown( + '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}" id="keep"}\n::probe{:text="props.id"}\n::\n:::\n::::', + { data: {} } + ) + expect(html).toContain('[keep]') + expect(html).not.toContain('[Outer]') + }) + + it('keeps the parent props visible through bare wrappers', async () => { + const html = await renderMarkdown('::::wrap{label="Outer"}\n- :probe{:text="props.label"}\n::::') + expect(html).toContain('
          ') + expect(html).toContain('[Outer]') + }) +}) diff --git a/packages/comark-react/test/renderer-data-binding.test.tsx b/packages/comark-react/test/renderer-data-binding.test.tsx index f9c1c08a..7f63c6fd 100644 --- a/packages/comark-react/test/renderer-data-binding.test.tsx +++ b/packages/comark-react/test/renderer-data-binding.test.tsx @@ -114,6 +114,28 @@ site: expect(html).not.toContain('frontmatter.missing') }) + it('interpolates placeholders into string props', async () => { + const html = await renderMarkdown( + `::badge{:label="Hi {{ data.name }}"} +:: + +:a[Docs]{:href="/d/{{ data.id }}"}`, + { components: { badge: Badge }, data: { name: 'Ada', id: 7 } } + ) + expect(html).toContain('Hi Ada') + expect(html).toContain('href="/d/7"') + }) + + it('omits a rejected URL while keeping the link text', async () => { + const md = ':a[x]{:href="{{ data.url }}"}' + const bad = await renderMarkdown(md, { data: { url: 'javascript:alert(1)' } }) + expect(bad).not.toContain('javascript:') + expect(bad).not.toContain('href') + expect(bad).toContain('x') + const ok = await renderMarkdown(md, { data: { url: '/ok' } }) + expect(ok).toContain('href="/ok"') + }) + it('leaves non-:prefix attributes untouched', async () => { const html = await renderMarkdown( `--- diff --git a/packages/comark-svelte/src/components/MarkdownNode.svelte b/packages/comark-svelte/src/components/MarkdownNode.svelte index 2bc3776d..67d1a3a0 100644 --- a/packages/comark-svelte/src/components/MarkdownNode.svelte +++ b/packages/comark-svelte/src/components/MarkdownNode.svelte @@ -191,11 +191,20 @@ naturally appears inline after the deepest trailing text node. // Only shadow the parent's `props` scope when the current element has its // own attributes. Bare wrappers (`

          `, `

            `, `
          • `, โ€ฆ) must keep the - // parent's scope so bindings like `{{ props.x }}` reach across them. + // parent's scope so bindings like `{{ props.x }}` reach across them. The + // check uses the raw attributes: resolving can omit all of them. let nativeProps = $derived(tag ? toNativeAttributes(tag, node, mappedProps) : mappedProps) + let hasOwnAttrs = $derived.by(() => { + const attrs: Record = (Array.isArray(node) && node.length >= 2 ? node[1] : null) ?? {} + for (const key in attrs) { + if (key !== '$') return true + } + return false + }) + let childrenRenderData = $derived( - Object.keys(mappedProps).length > 0 + hasOwnAttrs ? { ...renderData, props: mappedProps } : renderData, ) diff --git a/packages/comark-svelte/test/plugin-binding.test.ts b/packages/comark-svelte/test/plugin-binding.test.ts index e6e2b655..14541f22 100644 --- a/packages/comark-svelte/test/plugin-binding.test.ts +++ b/packages/comark-svelte/test/plugin-binding.test.ts @@ -12,3 +12,21 @@ describe('@comark/svelte plugins/binding โ€” If SSR', () => { expect(body.replace(/<[^>]*>/g, '').trim()).toBe(expected) }) }) + +describe('@comark/svelte prop interpolation SSR', () => { + it('resolves `{{ path }}` placeholders in :href', async () => { + const value = await parseMarkdown('Go :a[Docs]{:href="/d/{{ data.id }}"}') + const { body } = render(MarkdownDocument, { props: { value, data: { id: 7 } } }) + expect(body).toContain('href="/d/7"') + }) + + it('omits a rejected URL while keeping the link text', async () => { + const value = await parseMarkdown(':a[x]{:href="{{ data.url }}"}') + const bad = render(MarkdownDocument, { props: { value, data: { url: 'javascript:alert(1)' } } }).body + expect(bad).not.toContain('javascript:') + expect(bad).not.toContain('href') + expect(bad.replace(//g, '')).toContain('x') + const ok = render(MarkdownDocument, { props: { value, data: { url: '/ok' } } }).body + expect(ok).toContain('href="/ok"') + }) +}) diff --git a/packages/comark-svelte/test/props-scope.test.ts b/packages/comark-svelte/test/props-scope.test.ts new file mode 100644 index 00000000..8ce7c106 --- /dev/null +++ b/packages/comark-svelte/test/props-scope.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { render } from 'svelte/server' +import { parseMarkdown } from 'comark' +import MarkdownDocument from '../src/components/MarkdownDocument.svelte' +import Probe from './test-components/Probe.svelte' + +async function renderMarkdown(markdown: string, data: Record = {}) { + const value = await parseMarkdown(markdown) + return render(MarkdownDocument, { props: { value, components: { probe: Probe }, data } }).body +} + +describe('MarkdownDocument: props scope when every attribute is omitted', () => { + it('hides the grandparent props from children of an element with all attributes omitted', async () => { + const html = await renderMarkdown( + '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}"}\n::probe{:text="props.label"}\n::\n:::\n::::' + ) + expect(html).toContain('class="probe"') + expect(html).not.toContain('[Outer]') + }) + + it('exposes the resolved attributes of the middle element to its children', async () => { + const html = await renderMarkdown( + '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}" id="keep"}\n::probe{:text="props.id"}\n::\n:::\n::::' + ) + expect(html).toContain('[keep]') + expect(html).not.toContain('[Outer]') + }) + + it('keeps the parent props visible through bare wrappers', async () => { + const html = await renderMarkdown('::::wrap{label="Outer"}\n- :probe{:text="props.label"}\n::::') + expect(html).toContain('
              ') + expect(html).toContain('[Outer]') + }) +}) diff --git a/packages/comark-svelte/test/test-components/Probe.svelte b/packages/comark-svelte/test/test-components/Probe.svelte new file mode 100644 index 00000000..d4b3434a --- /dev/null +++ b/packages/comark-svelte/test/test-components/Probe.svelte @@ -0,0 +1,5 @@ + + +[{text}] diff --git a/packages/comark-vue/src/components/MarkdownDocument.ts b/packages/comark-vue/src/components/MarkdownDocument.ts index 1201fdd8..127cfdf0 100644 --- a/packages/comark-vue/src/components/MarkdownDocument.ts +++ b/packages/comark-vue/src/components/MarkdownDocument.ts @@ -171,8 +171,15 @@ function renderNode( // Only shadow the parent's `props` scope when the current element has its // own attributes. Bare wrappers (`

              `, `

                `, `
              • `, โ€ฆ) must keep the - // parent's scope so bindings like `{{ props.x }}` reach across them. - const hasOwnAttrs = Object.keys(resolved).length > 0 + // parent's scope so bindings like `{{ props.x }}` reach across them. The + // check uses the raw attributes: resolving can omit all of them. + let hasOwnAttrs = false + for (const key in nodeProps) { + if (key !== '$') { + hasOwnAttrs = true + break + } + } const childrenRenderData = hasOwnAttrs ? { ...renderData, props: scopeProps } : renderData // Separate template elements (slots) from regular children const slots: Record (VNode | string)[]> = {} diff --git a/packages/comark-vue/test/interpolation-reactivity.test.ts b/packages/comark-vue/test/interpolation-reactivity.test.ts new file mode 100644 index 00000000..d6cde84a --- /dev/null +++ b/packages/comark-vue/test/interpolation-reactivity.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { Suspense, createRenderer, h, nextTick, reactive } from 'vue' +import { parseMarkdown } from 'comark' +import { MarkdownDocument } from '../src/components/MarkdownDocument' + +interface N { + props: Record + parent: N | null + children: N[] + text?: string +} + +const node = (text?: string): N => ({ props: {}, parent: null, children: [], text }) +const { createApp } = createRenderer({ + patchProp(el, key, _prev, next) { + el.props[key] = next + }, + insert(child, parent, anchor) { + child.parent?.children.splice(child.parent.children.indexOf(child), 1) + child.parent = parent + const i = anchor ? parent.children.indexOf(anchor) : -1 + parent.children.splice(i < 0 ? parent.children.length : i, 0, child) + }, + remove(child) { + child.parent?.children.splice(child.parent.children.indexOf(child), 1) + child.parent = null + }, + createElement: () => node(), + createText: (text) => node(text), + createComment: (text) => node(text), + setText(n, text) { + n.text = text + }, + setElementText(n, text) { + n.children = [node(text)] + }, + parentNode: (n) => n.parent, + nextSibling: (n) => n.parent?.children[n.parent.children.indexOf(n) + 1] ?? null, +}) + +const titles = (n: N): unknown[] => [n.props.title, ...n.children.flatMap(titles)].filter((t) => t !== undefined) +const settle = async () => { + await nextTick() + await new Promise((resolve) => setTimeout(resolve, 10)) +} + +async function mount(markdown: string, data: Record): Promise { + const value = await parseMarkdown(markdown) + const root = node() + createApp({ render: () => h(Suspense, null, { default: () => h(MarkdownDocument, { value, data }) }) }).mount(root) + await settle() + return root +} + +describe('prop interpolation reactivity', () => { + it('updates when a data value changes', async () => { + const data = reactive({ name: 'Ada' }) + const root = await mount(':span[x]{:title="Hi {{ data.name }}"}', data) + expect(titles(root)).toEqual(['Hi Ada']) + data.name = 'Bob' + await settle() + expect(titles(root)).toEqual(['Hi Bob']) + }) + + it('updates when a key is added after the first render', async () => { + const data = reactive>({}) + const root = await mount(':span[x]{:title="Hi {{ data.extra || anon }}"}', data) + expect(titles(root)).toEqual(['Hi anon']) + data.extra = 'x' + await settle() + expect(titles(root)).toEqual(['Hi x']) + }) +}) diff --git a/packages/comark-vue/test/props-scope.test.ts b/packages/comark-vue/test/props-scope.test.ts new file mode 100644 index 00000000..555e958d --- /dev/null +++ b/packages/comark-vue/test/props-scope.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { createSSRApp, defineComponent, h } from 'vue' +import { renderToString } from '@vue/server-renderer' +import { parseMarkdown } from 'comark' +import { MarkdownDocument } from '../src/components/MarkdownDocument' + +const Probe = defineComponent({ + props: { text: { type: String, default: '' } }, + setup(props) { + return () => h('i', { class: 'probe' }, `[${props.text}]`) + }, +}) + +async function renderMarkdown(markdown: string, props: Record = {}) { + const tree = await parseMarkdown(markdown) + const app = createSSRApp({ + setup: () => () => h(MarkdownDocument, { value: tree, components: { probe: Probe }, ...props }), + }) + return renderToString(app as any) +} + +describe('MarkdownDocument: props scope when every attribute is omitted', () => { + it('hides the grandparent props from children of an element with all attributes omitted', async () => { + const html = await renderMarkdown( + '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}"}\n::probe{:text="props.label"}\n::\n:::\n::::', + { data: {} } + ) + expect(html).toContain('class="probe"') + expect(html).not.toContain('[Outer]') + }) + + it('exposes the resolved attributes of the middle element to its children', async () => { + const html = await renderMarkdown( + '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}" id="keep"}\n::probe{:text="props.id"}\n::\n:::\n::::', + { data: {} } + ) + expect(html).toContain('[keep]') + expect(html).not.toContain('[Outer]') + }) + + it('keeps the parent props visible through bare wrappers', async () => { + const html = await renderMarkdown('::::wrap{label="Outer"}\n- :probe{:text="props.label"}\n::::') + expect(html).toContain('
                  ') + expect(html).toContain('[Outer]') + }) +}) diff --git a/packages/comark-vue/test/renderer-data-binding.test.ts b/packages/comark-vue/test/renderer-data-binding.test.ts index 4473d456..c6eae43e 100644 --- a/packages/comark-vue/test/renderer-data-binding.test.ts +++ b/packages/comark-vue/test/renderer-data-binding.test.ts @@ -32,6 +32,13 @@ const Card = defineComponent({ }, }) +const VariantCard = defineComponent({ + props: { variant: { type: String, default: 'primary' } }, + setup(props) { + return () => h('div', { class: `card ${props.variant}` }) + }, +}) + async function renderMarkdown(markdown: string, props: Record = {}) { const tree = await parseMarkdown(markdown) const app = createSSRApp({ @@ -112,6 +119,35 @@ site: expect(html).not.toContain('frontmatter.missing') }) + it('interpolates placeholders into string props', async () => { + const html = await renderMarkdown( + `::badge{:label="Hi {{ data.name }}"} +:: + +:a[Docs]{:href="/d/{{ data.id }}"}`, + { components: { badge: Badge }, data: { name: 'Ada', id: 7 } } + ) + expect(html).toContain('Hi Ada') + expect(html).toContain('href="/d/7"') + }) + + it('omits a rejected URL while keeping the link text', async () => { + const md = ':a[x]{:href="{{ data.url }}"}' + const bad = await renderMarkdown(md, { data: { url: 'javascript:alert(1)' } }) + expect(bad).not.toContain('javascript:') + expect(bad).not.toContain('href') + expect(bad).toContain('x') + const ok = await renderMarkdown(md, { data: { url: '/ok' } }) + expect(ok).toContain('href="/ok"') + }) + + it('omits a prop whose sole placeholder is unresolved so the default applies', async () => { + const html = await renderMarkdown(`::card{:variant="{{ data.missing }}"}\n::`, { + components: { card: VariantCard }, + }) + expect(html).toContain('class="card primary"') + }) + it('leaves non-:prefix attributes untouched', async () => { const html = await renderMarkdown( `--- diff --git a/packages/comark/SPEC/COMARK/data-binding-interpolation.md b/packages/comark/SPEC/COMARK/data-binding-interpolation.md new file mode 100644 index 00000000..f9b0744e --- /dev/null +++ b/packages/comark/SPEC/COMARK/data-binding-interpolation.md @@ -0,0 +1,69 @@ +## Input + +```md +--- +id: 7 +slug: getting-started +--- + +::card{:title="Item {{ frontmatter.id }}" :link="/docs/{{ frontmatter.slug }}/{{ frontmatter.missing || overview }}" :note="{{ frontmatter.missing }}"} +:: + +::card +--- +:title: Item {{ frontmatter.id }} +:to: javascript:{{ frontmatter.slug }} +--- +:: +``` + +## AST + +```json +{ + "frontmatter": { + "id": 7, + "slug": "getting-started" + }, + "meta": {}, + "nodes": [ + [ + "card", + { + ":title": "Item {{ frontmatter.id }}", + ":link": "/docs/{{ frontmatter.slug }}/{{ frontmatter.missing || overview }}", + ":note": "{{ frontmatter.missing }}" + } + ], + [ + "card", + { + ":title": "Item {{ frontmatter.id }}", + ":to": "javascript:{{ frontmatter.slug }}" + } + ] + ] +} +``` + +## HTML + +```html + + +``` + +## Markdown + +```md +--- +id: 7 +slug: getting-started +--- + +::card{:title="Item {{ frontmatter.id }}" :link="/docs/{{ frontmatter.slug }}/{{ frontmatter.missing || overview }}" :note="{{ frontmatter.missing }}"} +:: + +::card{:title="Item {{ frontmatter.id }}" :to="javascript:{{ frontmatter.slug }}"} +:: +``` diff --git a/packages/comark/src/internal/stringify/attributes.ts b/packages/comark/src/internal/stringify/attributes.ts index 2ed7472f..6a6eafa7 100644 --- a/packages/comark/src/internal/stringify/attributes.ts +++ b/packages/comark/src/internal/stringify/attributes.ts @@ -2,6 +2,7 @@ import { stringifyYaml } from '../yaml.ts' import { escapeHtml, get } from '../../utils/index.ts' import { isUnsafeUrlValue } from '../props-validation.ts' import { pickFence } from './fence.ts' +import { interpolateProp } from './interpolate.ts' import type { NodeRenderData } from '../../types.ts' export interface ResolveAttributesOptions { @@ -39,6 +40,9 @@ const HTML_SINK_PROPS = new Set(['innerhtml', 'dangerouslysetinnerhtml', 'textco * With `parseJson: true`, every `:prefixed` string is JSON-parsed first and * the `:` prefix is always stripped, falling back to the dot-path lookup. * The `$` metadata key is never forwarded. + * + * A `:prefixed` string with `{{ path || default }}` placeholders is interpolated + * in both modes. The prop is omitted when nothing is available or the result is unsafe. */ export function resolveAttributes( attrs: Record, @@ -57,6 +61,15 @@ export function resolveAttributes( if (HTML_SINK_PROPS.has(lowerOutKey)) continue + if (isBinding && typeof value === 'string') { + const text = interpolateProp(outKey, value, renderData) + if (text !== undefined) { + // UNRESOLVED and REJECTED_PROP are symbols: the prop is omitted + if (typeof text === 'string') result[outKey] = text + continue + } + } + let outValue: unknown let resultKey = key @@ -115,6 +128,9 @@ export function resolveAttribute(attrs: Record, renderData: Nod if (bindKey in attrs) { const value = attrs[bindKey] if (typeof value === 'string') { + const text = interpolateProp(key, value, renderData) + if (typeof text === 'string') return text + if (text !== undefined) return attrs[key] const resolved = get(renderData, value) if (resolved !== undefined) return resolved } diff --git a/packages/comark/src/internal/stringify/interpolate.ts b/packages/comark/src/internal/stringify/interpolate.ts new file mode 100644 index 00000000..8de906d1 --- /dev/null +++ b/packages/comark/src/internal/stringify/interpolate.ts @@ -0,0 +1,107 @@ +import { get } from '../../utils/index.ts' +import { REJECTED_PROP, validateProp } from '../props-validation.ts' +import type { NodeRenderData } from '../../types.ts' + +/** Returned when the prop must be omitted because no value is available. */ +export const UNRESOLVED = Symbol('comark:unresolved-template') + +const MAX_LENGTH = 4096 +/** The render context namespaces. Other roots stay as written. */ +const ROOTS = new Set(['frontmatter', 'meta', 'data', 'props']) +/** Names that would reach the prototype chain. */ +const BLOCKED_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']) +/** Props that are not plain text, or that choose the rendered element. */ +const RESERVED_NAMES = new Set(['key', 'ref', 'is', 'as', 'style']) +const BAD_PATH_CHARS = /[\s{}[\]|'"]/ +/** Control and bidi override characters that must never reach a prop. */ +// oxlint-disable-next-line no-control-regex +const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u202A-\u202E\u2066-\u2069]/g + +/** One `{{ path || fallback }}` placeholder. */ +interface Placeholder { + path: string + fallback?: string +} + +/** Returns `undefined` when malformed, so the whole value stays as written. */ +function parsePlaceholder(inner: string): Placeholder | undefined { + const split = inner.indexOf('||') + const path = (split < 0 ? inner : inner.slice(0, split)).trim() + const segments = path.split('.') + if (!ROOTS.has(segments[0]!) || BAD_PATH_CHARS.test(path)) return undefined + if (segments.some((s) => s === '' || BLOCKED_SEGMENTS.has(s))) return undefined + return split < 0 ? { path } : { path, fallback: inner.slice(split + 2).trim() } +} + +/** Alternates text and placeholders, so the result always starts and ends with text. */ +function parseTemplate(value: string): Array | undefined { + const parts: Array = [] + let pos = 0 + for (let open = value.indexOf('{{'); open >= 0; open = value.indexOf('{{', pos)) { + const close = value.indexOf('}}', open + 2) + const placeholder = close < 0 ? undefined : parsePlaceholder(value.slice(open + 2, close)) + if (!placeholder) return undefined + parts.push(value.slice(pos, open), placeholder) + pos = close + 2 + } + parts.push(value.slice(pos)) + return parts +} + +/** Only text, numbers and booleans count. A throwing getter counts as missing. */ +function lookup(renderData: NodeRenderData, path: string): string | undefined { + try { + const found = get(renderData, path) + const ok = typeof found === 'string' || typeof found === 'boolean' || Number.isFinite(found) + return ok ? String(found) : undefined + } catch { + return undefined + } +} + +/** + * Resolve `{{ path || default }}` placeholders in the value of a `:prop`. + * Returns `undefined` when the value is not a template, so the caller keeps its usual behavior. + * Returns `UNRESOLVED` when the prop must be omitted, and `REJECTED_PROP` when the result is unsafe. + * Inserted text is never scanned again, so data cannot inject placeholders. + */ +export function interpolateProp( + name: string, + value: string, + renderData: NodeRenderData +): string | typeof UNRESOLVED | typeof REJECTED_PROP | undefined { + // A key such as `::title` is a nested binding, so leave it alone + if (value.length > MAX_LENGTH || !value.includes('{{') || name.startsWith(':')) return undefined + if (RESERVED_NAMES.has(name.toLowerCase().replace(/^[.^]/, ''))) return undefined + // A JSON string literal is the escape hatch that keeps the plain binding behavior + if (value[0] === '"') { + try { + JSON.parse(value) + return undefined + } catch { + // Not a JSON string: continue as a template + } + } + const parts = parseTemplate(value) + if (!parts) return undefined + + const sole = parts.length === 3 && parts[0] === '' && parts[2] === '' + let output = '' + for (const part of parts) { + if (typeof part === 'string') { + output += part + } else { + const found = lookup(renderData, part.path) ?? part.fallback + // A sole placeholder has no text around it, so omitting the prop is safer than an empty value + if (found === undefined && sole) return UNRESOLVED + // Skip cleaning a huge value, because it can only exceed the limit + if (found !== undefined && found.length > MAX_LENGTH) return UNRESOLVED + // Line breaks become a space, other control characters are removed + output += (found ?? '').replace(/[\t\r\n]+/g, ' ').replace(CONTROL_CHARS, '') + } + if (output.length > MAX_LENGTH) return UNRESOLVED + } + + // Check the final text, so a scheme split across pieces is caught + return validateProp(name, output) === REJECTED_PROP ? REJECTED_PROP : output +} diff --git a/packages/comark/src/internal/stringify/state.ts b/packages/comark/src/internal/stringify/state.ts index a18612d4..1cad594e 100644 --- a/packages/comark/src/internal/stringify/state.ts +++ b/packages/comark/src/internal/stringify/state.ts @@ -58,12 +58,22 @@ export async function one(node: Node, state: State, parent?: ElementNode, atLine // element's values, regardless of which handler (html / ansi / user) runs. // Elements with no attributes (e.g. an auto-generated `

                  ` wrapper) must // NOT shadow the parent's scope, otherwise `{{ props.* }}` inside them would - // resolve to nothing. + // resolve to nothing. The check uses the raw attributes, because resolving + // can drop all of them and the parent's props must not leak back in. const prevRenderData = state.renderData if (state.renderData && node[1]) { - const resolved = resolveAttributes(node[1] as Record, prevRenderData) - if (Object.keys(resolved).length > 0) { - state.renderData = { ...prevRenderData, props: resolved } + let hasOwnAttrs = false + for (const key in node[1]) { + if (key !== '$') { + hasOwnAttrs = true + break + } + } + if (hasOwnAttrs) { + state.renderData = { + ...prevRenderData, + props: resolveAttributes(node[1] as Record, prevRenderData), + } } } diff --git a/packages/comark/test/dos.test.ts b/packages/comark/test/dos.test.ts index 542ae62e..69a085e8 100644 --- a/packages/comark/test/dos.test.ts +++ b/packages/comark/test/dos.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { parseMarkdown } from '../src/parse' import { renderMarkdown } from '../src/render' +import { resolveAttributes } from '../src/internal/stringify/attributes' describe('denial-of-service hardening', () => { it('parses thousands of unclosed nested inline HTML tags without stack overflow', async () => { @@ -52,4 +53,70 @@ describe('denial-of-service hardening', () => { // No `>` anywhere, so nothing is tag-like and the text stays untouched expect(md.trim()).toBe(text) }) + + describe('prop template interpolation', () => { + const renderData = { frontmatter: {}, meta: {}, data: { x: 'v' }, props: {} } + const modes = [ + { label: 'default mode', options: {} }, + { label: 'parseJson mode', options: { parseJson: true } }, + ] + + for (const { label, options } of modes) { + it(`resolves hundreds of placeholders in one template quickly in ${label}`, () => { + const start = performance.now() + const result = resolveAttributes({ ':title': '{{ data.x }}'.repeat(340) }, renderData, options) + expect(result).toEqual({ title: 'v'.repeat(340) }) + expect(performance.now() - start).toBeLessThan(500) + }) + + it(`resolves a long default value quickly in ${label}`, () => { + const start = performance.now() + const result = resolveAttributes( + { ':title': '{{ data.missing || ' + 'a'.repeat(3900) + ' }}' }, + renderData, + options + ) + expect(result).toEqual({ title: 'a'.repeat(3900) }) + expect(performance.now() - start).toBeLessThan(500) + }) + } + + it('keeps a flood of unclosed `{{` as written without rescanning', () => { + const value = '{{ '.repeat(1_300) + const start = performance.now() + const result = resolveAttributes({ ':title': value }, renderData) + expect(result).toEqual({ ':title': value }) + expect(performance.now() - start).toBeLessThan(200) + }) + + it('drops a flood of unclosed `{{` in parseJson mode without rescanning', () => { + const start = performance.now() + const result = resolveAttributes({ ':title': '{{ '.repeat(1_300) }, renderData, { parseJson: true }) + expect(result).toHaveProperty('title', undefined) + expect(Object.keys(result)).toEqual(['title']) + expect(performance.now() - start).toBeLessThan(200) + }) + + it('keeps a closed placeholder followed by unclosed `{{` as written', () => { + const value = '{{ data.x }}{{ '.repeat(250) + const start = performance.now() + const result = resolveAttributes({ ':title': value }, renderData) + expect(result).toEqual({ ':title': value }) + expect(performance.now() - start).toBeLessThan(200) + }) + + it('drops a closed placeholder followed by unclosed `{{` in parseJson mode', () => { + const start = performance.now() + const result = resolveAttributes({ ':title': '{{ data.x }}{{ '.repeat(250) }, renderData, { parseJson: true }) + expect(result).toHaveProperty('title', undefined) + expect(Object.keys(result)).toEqual(['title']) + expect(performance.now() - start).toBeLessThan(200) + }) + + it('leaves a template over the length limit as written', () => { + const value = '{{ data.x }}'.repeat(500) + const result = resolveAttributes({ ':title': value }, renderData) + expect(result).toEqual({ ':title': value }) + }) + }) }) diff --git a/packages/comark/test/interpolate.test.ts b/packages/comark/test/interpolate.test.ts new file mode 100644 index 00000000..ac519216 --- /dev/null +++ b/packages/comark/test/interpolate.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest' +import type { NodeRenderData } from '../src/types.ts' +import { REJECTED_PROP } from '../src/internal/props-validation.ts' +import { UNRESOLVED, interpolateProp } from '../src/internal/stringify/interpolate.ts' + +const makeRenderData = (overrides: Partial = {}): NodeRenderData => ({ + frontmatter: {}, + meta: {}, + data: {}, + props: {}, + ...overrides, +}) + +const withData = (data: Record): NodeRenderData => makeRenderData({ data }) +const run = (value: string, data: Record = {}, name = 'title') => + interpolateProp(name, value, withData(data)) + +describe('interpolateProp (resolution)', () => { + it.each([ + ['/docs/{{ data.slug }}', '/docs/intro'], + ['/docs/{{ data.slug }}/overview', '/docs/intro/overview'], + ['{{ data.slug }}/overview', 'intro/overview'], + ['{{data.slug}}-{{ data.slug }}', 'intro-intro'], + ['{{ data.items.0.name }}', 'first'], + ])('replaces placeholders in %s', (value, expected) => { + expect(run(value, { slug: 'intro', items: [{ name: 'first' }] })).toBe(expected) + }) + + it('reads all four roots', () => { + const renderData = { frontmatter: { a: 'A' }, meta: { b: 'B' }, data: { c: 'C' }, props: { d: 'D' } } + expect(interpolateProp('title', '{{ frontmatter.a }}{{ meta.b }}{{ data.c }}{{ props.d }}', renderData)).toBe( + 'ABCD' + ) + }) + + it.each([ + ['x-{{ data.missing || fallback }}', 'x-fallback'], + ['x-{{ data.missing || }}', 'x-'], + ["x-{{ data.missing || 'q' }}", "x-'q'"], + ['x-{{ data.missing || a || b }}', 'x-a || b'], + ['x-{{ data.slug || fallback }}', 'x-intro'], + ['x-{{ data.missing }}', 'x-'], + ])('handles defaults in %s', (value, expected) => { + expect(run(value, { slug: 'intro' })).toBe(expected) + }) + + it('inserts 0, false and empty strings without using the default', () => { + expect(run('{{ data.n || d }}|{{ data.f || d }}|{{ data.s || d }}', { n: 0, f: false, s: '' })).toBe('0|false|') + }) + + it('stringifies numbers and booleans', () => { + expect(run('a{{ data.n }}{{ data.t }}', { n: 1.5, t: true })).toBe('a1.5true') + }) + + it.each([ + ['object', {}], + ['array', []], + ['null', null], + ['undefined', undefined], + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['function', () => 'x'], + ])('treats a %s value as unresolved', (_, value) => { + expect(run('a{{ data.v || d }}', { v: value })).toBe('ad') + expect(run('a{{ data.v }}', { v: value })).toBe('a') + }) + + it('returns UNRESOLVED only for a sole placeholder without a default', () => { + expect(run('{{ data.x }}')).toBe(UNRESOLVED) + expect(run('{{ data.x || d }}')).toBe('d') + expect(run('{{ data.x || }}')).toBe('') + expect(run('{{ data.x }}!')).toBe('!') + expect(run('{{ data.x }}{{ data.y }}')).toBe('') + }) + + it('never calls functions or reads inherited methods', () => { + const fn = vi.fn(() => 'called') + expect(run('{{ data.fn || d }}', { fn })).toBe('d') + expect(run('{{ data.toString || d }}')).toBe('d') + expect(fn).not.toHaveBeenCalled() + }) + + it('treats a throwing getter as unresolved', () => { + const data = { + get boom(): string { + throw new Error('boom') + }, + } + expect(run('a{{ data.boom || d }}', data)).toBe('ad') + }) + + it('does not expand placeholders inside inserted values', () => { + expect(run('{{ data.a }}', { a: '{{ data.secret }}', secret: 's' })).toBe('{{ data.secret }}') + }) + + it('collapses line breaks and removes control characters', () => { + expect(run('{{ data.a }}', { a: 'a\r\n\t b' })).toBe('a b') + expect(run('{{ data.a }}', { a: 'a\n\n\nb' })).toBe('a b') + expect(run('{{ data.a }}', { a: 'a\u0000b\u0085c\u202Ed\u2066e\u007Ff' })).toBe('abcdef') + expect(run('{{ data.x || d\u0000e }}')).toBe('de') + }) + + it('returns UNRESOLVED when the output is too long, without building it', () => { + const start = performance.now() + expect(run('a{{ data.a }}', { a: 'x'.repeat(5_000_000) })).toBe(UNRESOLVED) + expect(run('{{ data.a }}{{ data.a }}', { a: 'x'.repeat(3000) })).toBe(UNRESOLVED) + expect(performance.now() - start).toBeLessThan(200) + }) +}) + +describe('interpolateProp (fall through)', () => { + it.each([ + 'plain text', + 'only {{ open', + 'only close }}', + '{{ x }}', + '{{ other.x }}', + '{{ a | b }}', + '{{ data.a b }}', + '{{ }}', + '{{ || d }}', + '{{ data.a..b }}', + '{{ data.items[0] }}', + '{{ data.a"b }}', + "{{ data.a'b }}", + '{{ {{ data.x }} }}', + '{{ data.__proto__ }}', + '{{ data.constructor }}', + '{{ data.a.prototype }}', + '"{{ data.x }}"', + '{{ data.x }} {{ unterminated', + `${'x'.repeat(4090)}{{ data.x }}`, + ])('returns undefined for %s', (value) => { + expect(run(value, { x: 'v' })).toBeUndefined() + }) + + it.each(['key', 'ref', 'is', 'as', 'style', '.style', '^style', 'STYLE', ':href'])( + 'returns undefined for the name %s', + (name) => { + expect(interpolateProp(name, 'a{{ data.x }}', withData({ x: 'v' }))).toBeUndefined() + } + ) + + it('returns quickly for inputs over the length gate', () => { + const start = performance.now() + expect(run(`{{${' '.repeat(100000)}`)).toBeUndefined() + expect(run('{{ '.repeat(1500))).toBeUndefined() + expect(performance.now() - start).toBeLessThan(200) + }) + + it('returns quickly for hostile shapes under the length gate', () => { + const start = performance.now() + expect(run('{{ data.x }}'.repeat(340), { x: 'v' })).toBe('v'.repeat(340)) + expect(run('{{ '.repeat(1300))).toBeUndefined() + expect(run('{{ data.x }}{{ '.repeat(300))).toBeUndefined() + expect(run(`{{ data.x || ${'a'.repeat(3900)} }}`)).toBe('a'.repeat(3900)) + expect(run('}} {{ '.repeat(700))).toBeUndefined() + expect(run('{{ data.x }}'.repeat(300) + '}}'.repeat(200), { x: 'v' })).toBe('v'.repeat(300) + '}}'.repeat(200)) + expect(performance.now() - start).toBeLessThan(200) + }) +}) + +describe('interpolateProp (validation)', () => { + const urlNames = ['href', 'to', 'src', 'action', 'poster'] + const dangerous = [ + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + ' javascript:alert(1)', + 'javascript:alert(1)', + 'javascript%3Aalert(1)', + 'data:text/html,x', + ] + + it.each(urlNames.flatMap((name) => dangerous.map((value) => [name, value])))('rejects %s=%s', (name, value) => { + expect(run('{{ data.u }}', { u: value }, name)).toBe(REJECTED_PROP) + expect(run('{{ data.u || d }}', { u: value }, name)).toBe(REJECTED_PROP) + }) + + it('rejects schemes built from several pieces', () => { + expect(run('{{ data.a }}{{ data.b }}', { a: 'java', b: 'script:alert(1)' }, 'href')).toBe(REJECTED_PROP) + expect(run('java{{ data.x }}script:alert(1)', {}, 'href')).toBe(REJECTED_PROP) + expect(run('java{{ data.x }}script:alert(1)', { x: '' }, 'href')).toBe(REJECTED_PROP) + }) + + it('does not emit a tab split scheme as javascript', () => { + const result = run('{{ data.a }}script:alert(1)', { a: 'java\t' }, 'href') + expect(result).toBe('java script:alert(1)') + expect(String(result).startsWith('javascript:')).toBe(false) + }) + + // The checks match path bindings such as `:href="data.x"` + it.each([ + 'https://a/b', + '/p?x=1', + '#frag', + 'mailto:a@b.c', + 'tel:+1', + '//cdn.example/x', + '/a:b', + '?q=a:b', + 'data:image/png;base64,AAA', + ])('accepts %s for href', (value) => { + expect(run('{{ data.u }}', { u: value }, 'href')).toBe(value) + }) + + it('accepts dangerous looking text for a non-URL name', () => { + expect(run('{{ data.t }}', { t: 'JavaScript: The Good Parts' })).toBe('JavaScript: The Good Parts') + expect(run('{{ data.t }}', { t: 'javascript:alert(1)' }, 'title')).toBe('javascript:alert(1)') + }) + + it.each(['onclick', 'srcdoc', 'formaction', 'innerHTML', 'onlyfoo'])('rejects the unsafe name %s', (name) => { + expect(run('a{{ data.x }}', { x: 'safe' }, name)).toBe(REJECTED_PROP) + }) + + it('does not throw on malformed percent encoding', () => { + expect(run('/a/{{ data.x }}', { x: '100%' }, 'href')).toBe('/a/100%') + expect(run('{{ data.x }}', { x: '%E0%A4%A' }, 'href')).toBe('%E0%A4%A') + }) +}) diff --git a/packages/comark/test/resolve-attributes.test.ts b/packages/comark/test/resolve-attributes.test.ts index e234dd12..eb482e19 100644 --- a/packages/comark/test/resolve-attributes.test.ts +++ b/packages/comark/test/resolve-attributes.test.ts @@ -287,3 +287,138 @@ describe('resolveAttribute (single-attr lookup)', () => { expect(value).toBe(config) }) }) + +describe('resolveAttributes (interpolation)', () => { + const renderData = makeRenderData({ + frontmatter: { slug: 'getting-started', n: 7 }, + data: { x: 'X', secret: 'S', a: 'java', b: 'script:alert(1)', bad: 'javascript:alert(1)', flag: true }, + props: { title: 'Ada' }, + }) + const modes = [ + ['default', {}], + ['parseJson', { parseJson: true }], + ] as const + + describe.each(modes)('%s mode', (_name, options) => { + it('resolves a mixed template and drops the : prefix', () => { + const result = resolveAttributes({ ':href': '/docs/{{ frontmatter.slug }}/overview' }, renderData, options) + expect(result).toEqual({ href: '/docs/getting-started/overview' }) + }) + + it('inserts numbers and booleans as text', () => { + const result = resolveAttributes({ ':title': 'Item {{ frontmatter.n }} {{ data.flag }}' }, renderData, options) + expect(result).toEqual({ title: 'Item 7 true' }) + }) + + it('uses the default when the path is missing', () => { + const result = resolveAttributes({ ':title': 'Hi {{ data.nope || friend }}' }, renderData, options) + expect(result).toEqual({ title: 'Hi friend' }) + }) + + it('omits the prop when a sole placeholder has nothing available', () => { + const result = resolveAttributes({ ':title': '{{ data.nope }}', id: 'keep' }, renderData, options) + expect(result).toEqual({ id: 'keep' }) + expect(result).not.toHaveProperty('title') + expect(result).not.toHaveProperty(':title') + }) + + it('resolves the props scope', () => { + const result = resolveAttributes({ ':label': 'Hi {{ props.title }}' }, renderData, options) + expect(result).toEqual({ label: 'Hi Ada' }) + }) + + it('leaves a non-: prop with a placeholder literal', () => { + const result = resolveAttributes({ title: 'Hi {{ data.x }}' }, renderData, options) + expect(result).toEqual({ title: 'Hi {{ data.x }}' }) + }) + + it('omits :href, :to and :src when the result has an unsafe scheme', () => { + const result = resolveAttributes( + { ':href': '{{ data.bad }}', ':to': '{{ data.bad }}', ':src': '{{ data.bad }}', id: 'keep' }, + renderData, + options + ) + expect(result).toEqual({ id: 'keep' }) + }) + + it('omits a URL whose scheme is split across placeholders', () => { + const result = resolveAttributes({ ':href': '{{ data.a }}{{ data.b }}' }, renderData, options) + expect(result).toEqual({}) + }) + + it('keeps a non-URL prop whose text looks like a scheme', () => { + const result = resolveAttributes({ ':title': 'JavaScript: {{ data.x }}' }, renderData, options) + expect(result).toEqual({ title: 'JavaScript: X' }) + }) + + it('omits event handler props', () => { + const result = resolveAttributes({ ':onclick': '{{ data.x }}', id: 'keep' }, renderData, options) + expect(result).toEqual({ id: 'keep' }) + }) + + it('leaves a nested object value untouched', () => { + const result = resolveAttributes({ ':config': { k: '{{ data.x }}' } }, renderData, options) + expect(Object.values(result)).toEqual([{ k: '{{ data.x }}' }]) + }) + + it('is stable when resolved twice and never expands inserted text', () => { + const data = makeRenderData({ data: { x: '{{ data.secret }}', secret: 'S' } }) + const once = resolveAttributes({ ':title': 'A {{ data.x }}' }, data, options) + expect(once).toEqual({ title: 'A {{ data.secret }}' }) + expect(resolveAttributes(once, data, options)).toEqual(once) + }) + }) + + it('keeps today behavior for a malformed template (default mode)', () => { + const result = resolveAttributes({ ':title': 'Hi {{ data.x' }, renderData) + expect(result).toEqual({ ':title': 'Hi {{ data.x' }) + }) + + it('keeps today behavior for a malformed template (parseJson mode)', () => { + const result = resolveAttributes({ ':title': 'Hi {{ data.x' }, renderData, { parseJson: true }) + expect(result).toHaveProperty('title', undefined) + }) + + it('keeps today behavior for an unknown root (default mode)', () => { + const result = resolveAttributes({ ':title': 'Hi {{ item.name }}' }, renderData) + expect(result).toEqual({ ':title': 'Hi {{ item.name }}' }) + }) + + it('keeps today behavior for an unknown root (parseJson mode)', () => { + const result = resolveAttributes({ ':title': 'Hi {{ item.name }}' }, renderData, { parseJson: true }) + expect(result).toHaveProperty('title', undefined) + }) + + it('keeps today behavior for a JSON string literal', () => { + const value = '"{{ data.x }}"' + expect(resolveAttributes({ ':title': value }, renderData)).toEqual({ ':title': value }) + expect(resolveAttributes({ ':title': value }, renderData, { parseJson: true })).toEqual({ title: '{{ data.x }}' }) + }) + + it.each([':style', ':key', ':is', ':as'])('keeps today behavior for reserved prop %s', (key) => { + const value = 'a {{ data.x }}' + expect(resolveAttributes({ [key]: value }, renderData)).toEqual({ [key]: value }) + expect(resolveAttributes({ [key]: value }, renderData, { parseJson: true })).toHaveProperty(key.slice(1), undefined) + }) +}) + +describe('resolveAttribute (interpolation)', () => { + const renderData = makeRenderData({ frontmatter: { slug: 'docs' }, data: { bad: 'javascript:alert(1)' } }) + + it('returns the interpolated string', () => { + expect(resolveAttribute({ ':href': '/{{ frontmatter.slug }}/x' }, renderData, 'href')).toBe('/docs/x') + }) + + it('returns the literal attribute when the template is rejected', () => { + expect(resolveAttribute({ ':href': '{{ data.bad }}' }, renderData, 'href')).toBeUndefined() + expect(resolveAttribute({ ':href': '{{ data.bad }}', href: '/safe' }, renderData, 'href')).toBe('/safe') + }) + + it('returns the literal attribute when a sole placeholder is unresolved', () => { + expect(resolveAttribute({ ':href': '{{ data.nope }}' }, renderData, 'href')).toBeUndefined() + }) + + it('keeps today behavior for a malformed template', () => { + expect(resolveAttribute({ ':href': '/x/{{ data.y' }, renderData, 'href')).toBe('/x/{{ data.y') + }) +}) From 2a6f83c681307cf23b6c13c789c507dbb219a9fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Mon, 19 Oct 2026 13:19:58 +0200 Subject: [PATCH 2/4] refactor(props): interpolate `:prop` templates only with `binding()` `{{ path || default }}` inside a `:` prop is now opt-in, like `{{ }}` in text. The binding plugin marks elements whose `:` attrs hold a well-formed template with `$.bind`, and the resolvers interpolate only marked nodes. Without the plugin, values stay as on main. Plain props are never interpolated. - Share the template grammar in `internal/template.ts` - Reject URL templates where fixed text and data join into another host (`/{{ x }}`), and with `security()` require the literal prefix to fix the scheme and host and pass the allowlists - Run interpolated values through the same hard floor as path bindings - Never print `$` metadata in `comarkAttributes()` - Move the props scope fix to its own PR --- AGENTS.md | 19 ++- docs/content/2.syntax/2.components.md | 17 +-- docs/content/4.plugins/1.built-in/binding.md | 18 ++- docs/content/4.plugins/1.built-in/security.md | 18 ++- .../src/components/markdown-node.component.ts | 19 +-- .../comark-angular/test/props-scope.test.ts | 69 ----------- packages/comark-ansi/test/index.test.ts | 7 +- .../comark-html/test/plugin-binding.test.ts | 2 +- packages/comark-html/test/props-scope.test.ts | 33 ----- packages/comark-html/test/string.test.ts | 13 +- .../src/components/MarkdownDocument.tsx | 11 +- .../comark-react/test/props-scope.test.tsx | 46 ------- .../test/renderer-data-binding.test.tsx | 11 +- .../src/components/MarkdownNode.svelte | 13 +- .../comark-svelte/test/plugin-binding.test.ts | 6 +- .../comark-svelte/test/props-scope.test.ts | 34 ------ .../test/test-components/Probe.svelte | 5 - .../src/components/MarkdownDocument.ts | 11 +- .../test/interpolation-reactivity.test.ts | 3 +- packages/comark-vue/test/props-scope.test.ts | 46 ------- .../test/renderer-data-binding.test.ts | 12 +- .../data-binding-interpolation-disabled.md | 50 ++++++++ .../SPEC/COMARK/data-binding-interpolation.md | 16 ++- .../comark/src/internal/props-validation.ts | 40 ++++++- .../src/internal/stringify/attributes.ts | 37 +++--- .../src/internal/stringify/interpolate.ts | 65 ++++------ .../comark/src/internal/stringify/state.ts | 18 +-- packages/comark/src/internal/template.ts | 49 ++++++++ packages/comark/src/plugins/binding.ts | 26 ++++ packages/comark/src/types.ts | 2 + packages/comark/test/dos.test.ts | 18 +-- packages/comark/test/plugins/binding.test.ts | 66 ++++++++++ packages/comark/test/props-validation.test.ts | 49 ++++++++ .../comark/test/resolve-attributes.test.ts | 113 +++++++++++++----- 34 files changed, 535 insertions(+), 427 deletions(-) delete mode 100644 packages/comark-angular/test/props-scope.test.ts delete mode 100644 packages/comark-html/test/props-scope.test.ts delete mode 100644 packages/comark-react/test/props-scope.test.tsx delete mode 100644 packages/comark-svelte/test/props-scope.test.ts delete mode 100644 packages/comark-svelte/test/test-components/Probe.svelte delete mode 100644 packages/comark-vue/test/props-scope.test.ts create mode 100644 packages/comark/SPEC/COMARK/data-binding-interpolation-disabled.md create mode 100644 packages/comark/src/internal/template.ts diff --git a/AGENTS.md b/AGENTS.md index de5caaeb..0493d56c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,8 @@ packages/comark/ โ”‚ โ”œโ”€โ”€ frontmatter.ts # parseFrontmatter() / renderFrontmatter() โ”‚ โ”œโ”€โ”€ heading-id.ts # Auto heading id generation (shared by parser + stringifier) โ”‚ โ”œโ”€โ”€ yaml.ts # YAML helpers -โ”‚ โ”œโ”€โ”€ props-validation.ts # Component props validation +โ”‚ โ”œโ”€โ”€ props-validation.ts # Component props validation (incl. anchored `{{ }}` URL templates) +โ”‚ โ”œโ”€โ”€ template.ts # `{{ path || default }}` grammar shared by the binding plugin and the resolver โ”‚ โ”œโ”€โ”€ parse/ # Parsing pipeline โ”‚ โ”‚ โ”œโ”€โ”€ token-processor.ts # markdown-exit tokens โ†’ document nodes โ”‚ โ”‚ โ”œโ”€โ”€ auto-close/ # Self-healing for incomplete markdown @@ -105,7 +106,7 @@ packages/comark/ โ”‚ โ”‚ โ”œโ”€โ”€ indent.ts # Dedent of outdented component children โ”‚ โ”‚ โ”œโ”€โ”€ unwrap.ts # `unwrap` option โ”‚ โ”‚ โ””โ”€โ”€ auto-unwrap.ts # `autoUnwrap` option -โ”‚ โ””โ”€โ”€ stringify/ # AST โ†’ markdown string rendering (handlers/ per tag) +โ”‚ โ””โ”€โ”€ stringify/ # AST โ†’ markdown string rendering (handlers/ per tag; interpolate.ts resolves `:prop` templates) โ”œโ”€โ”€ SPEC/ # Behavioral spec fixtures (CommonMark, GFM, HTML, COMARK, auto-close.md) โ”œโ”€โ”€ test/ # Vitest test files โ”œโ”€โ”€ package.json @@ -700,6 +701,20 @@ Example: } ``` +## Templates in `:` props + +With `binding()`, a `:` prop may mix text and placeholders: `:href="/docs/{{ data.slug }}"`. +The plugin's `post` hook marks each element whose `:` attrs hold a well-formed template with +`$.bind: 1` (grammar in `src/internal/template.ts`). `resolveAttributes` / `resolveAttribute` +interpolate only marked nodes (`src/internal/stringify/interpolate.ts`), so without the plugin +the values stay as written. Plain props are never interpolated. `$` is parser metadata: +`comarkAttributes()` never prints it. + +Security: the interpolated string goes through `validateProp` and the binding hard floor. In URL +props, fixed text and data must not join into another host (`/{{ x }}`). With `security()`, the +literal prefix before the first `{{` must fix the scheme and host and pass the allowlists +(`validateUrlTemplate` in `src/internal/props-validation.ts`). + ## Indentation Inside Components A block component's children may be indented (aligned under the `::` marker) or diff --git a/docs/content/2.syntax/2.components.md b/docs/content/2.syntax/2.components.md index e3a05483..34188a88 100644 --- a/docs/content/2.syntax/2.components.md +++ b/docs/content/2.syntax/2.components.md @@ -316,13 +316,13 @@ Need to interpolate a value directly into text rather than a prop? The [Binding ### String interpolation -Mix fixed text with data in a prop by wrapping a path in `{{ }}`. The renderer resolves each placeholder against the same render context and passes the result as a string: +With the [Binding plugin](/plugins/built-in/binding) enabled, a `:` prop can mix fixed text with data by wrapping a path in `{{ }}`. The renderer resolves each placeholder against the same render context and passes the result as a string: ```mdc :a[Read more]{:href="/docs/{{ data.slug }}/overview"} ``` -With `data` set to `{ slug: 'getting-started' }`, the link renders with `href="/docs/getting-started/overview"`. It works in YAML block props too: +With `data` set to `{ slug: 'getting-started' }`, the link renders with `href="/docs/getting-started/overview"`. It works in YAML block props and in HTML attributes too: ```mdc ::card @@ -330,6 +330,8 @@ With `data` set to `{ slug: 'getting-started' }`, the link renders with `href="/ :title: Item {{ frontmatter.id }} --- :: + +Docs ``` Add a fallback with `||`. Like text bindings, the default is plain text, so quotes are kept: @@ -340,16 +342,15 @@ Add a fallback with `||`. Like text bindings, the default is plain text, so quot #### Key rules -- Paths use the same namespaces as above. No plugin is needed. +- Interpolation needs the `binding()` plugin. Without it, `{{ }}` in a `:` prop stays as written. +- Only `:` props are interpolated. A plain prop such as `title="Hi {{ data.x }}"` or `` is always literal. +- Paths use the same namespaces as above. - The result is always a string. Use the path form (`:count="data.n"`, `:value="data.off"`) for numbers and booleans, because `false` becomes the text `false`, which is truthy. - A missing value uses the default, or an empty string. If the whole prop is one placeholder with nothing to show, the prop is omitted. - Values are inserted as is, without URL encoding. Unsafe URL schemes such as `javascript:` and unsafe prop names (`on*`, `srcdoc`) omit the prop. +- A URL prop is also omitted when fixed text and data join into another host, such as `/{{ data.path }}` with `/evil.com`. See [Interpolated props](/plugins/built-in/security#interpolated-props) for the URL rules. - Quote inline attribute values, and quote YAML values that start with `{{`. -- Props without the `:` prefix, the `key`, `ref`, `is`, `as` and `style` props, malformed placeholders and JSON values are left as written. - -::callout{color="warning" icon="i-lucide-triangle-alert"} -A `:` prop that already contained `{{ }}` text now interpolates. To keep literal braces, use a prop without the `:` prefix. -:: +- The `key`, `ref`, `is`, `as`, and `style` props, malformed placeholders, and JSON values are left as written. ## Slots diff --git a/docs/content/4.plugins/1.built-in/binding.md b/docs/content/4.plugins/1.built-in/binding.md index 805a34a9..3ffd9433 100644 --- a/docs/content/4.plugins/1.built-in/binding.md +++ b/docs/content/4.plugins/1.built-in/binding.md @@ -282,7 +282,23 @@ A binding value (`{{ path }}`) is resolved as a dot-path against the same render See [Data Binding](/syntax/components#data-binding) for the full contract and additional examples. -The same render context also resolves `{{ path || default }}` inside `:` props, such as `:a[Docs]{:href="/docs/{{ data.slug }}"}`. That feature needs no plugin, because the `binding()` plugin only handles `{{ }}` in text. See [String interpolation](/syntax/components#string-interpolation). +## Interpolation in props + +The plugin also enables `{{ path || default }}` inside `:` props, resolved against the same render context: + +```mdc +:a[Docs]{:href="/docs/{{ data.slug }}"} + +::card +--- +:title: Version {{ frontmatter.version || 1.0 }} +--- +:: + +Docs +``` + +The parser marks the elements whose `:` props hold a well-formed template, and the renderers interpolate them. Without the plugin, those values stay as written. Plain props without `:` are never interpolated. The interpolated value is always a string. See [String interpolation](/syntax/components#string-interpolation) for the full rules. ## Default values diff --git a/docs/content/4.plugins/1.built-in/security.md b/docs/content/4.plugins/1.built-in/security.md index a266632f..0ba31877 100644 --- a/docs/content/4.plugins/1.built-in/security.md +++ b/docs/content/4.plugins/1.built-in/security.md @@ -139,9 +139,23 @@ The same check applies to `:href` and `:src` bindings twice: on the JSON-decoded ### Interpolated props -A `:` prop can contain `{{ path }}` placeholders (see [String interpolation](/syntax/components#string-interpolation)). Comark checks the final string after it inserts the values, even without the `security()` plugin. Unsafe prop names and unsafe URL schemes are omitted. Path bindings are not checked this way: they only get the render-time URL check for `href`, `src`, `xlink:href` and `to`. +With the `binding()` plugin, a `:` prop can contain `{{ path }}` placeholders (see [String interpolation](/syntax/components#string-interpolation)). Comark checks the final string after it inserts the values, even without the `security()` plugin. Unsafe prop names and unsafe URL schemes are omitted, including a scheme split between fixed text and data, such as `:to="javascri{{ frontmatter.rest }}"`. -`allowedLinkPrefixes`, `allowedProtocols` and `defaultOrigin` check literal values at parse time. They do not apply to the final interpolated URL, so avoid building URLs from untrusted data. +In a URL prop, data can only fill what comes after the fixed text, so the fixed text before the first placeholder must set the scheme and host: + +| Template | Result | +| --- | --- | +| `/docs/{{ data.slug }}`, `?q={{ data.q }}`, `https://myapp.com/{{ data.slug }}` | Kept | +| `{{ data.url }}` | Kept: the data sets the whole URL, and the result still gets the scheme check | +| `/{{ data.path }}` | Omitted: data such as `/evil.com` would make `//evil.com` | +| `https://myapp.com{{ data.host }}` | Omitted with `security()`: data such as `.evil.com` would change the host | +| `java{{ data.rest }}` | Omitted with `security()`: data could finish the scheme | + +With `security()`, `allowedLinkPrefixes`, `allowedImagePrefixes`, and `allowedProtocols` are checked against that fixed text at parse time. A template that needs a `defaultOrigin` rewrite is omitted. A template that starts with a placeholder is treated like a path binding. + +Values are inserted without URL encoding: `?q={{ data.q }}` passes `&` and `#` through. Only interpolate trusted values, or values you encoded yourself. + +Path bindings (`:href="data.url"`) are not checked against the allowlists at render time. They only get the render-time URL check for `href`, `src`, `xlink:href`, and `to`. --- diff --git a/packages/comark-angular/src/components/markdown-node.component.ts b/packages/comark-angular/src/components/markdown-node.component.ts index f6c38118..50cc7de8 100644 --- a/packages/comark-angular/src/components/markdown-node.component.ts +++ b/packages/comark-angular/src/components/markdown-node.component.ts @@ -138,15 +138,8 @@ export class MarkdownNode implements OnChanges { // Resolve attributes (:binding support) const resolved = resolveAttributes(nodeProps, this.renderData, { parseJson: true }) - // Build childrenRenderData - only shadow parent scope when element has own attrs. - // The check uses the raw attributes: resolving can omit all of them. - let hasOwnAttrs = false - for (const key in nodeProps) { - if (key !== '$') { - hasOwnAttrs = true - break - } - } + // Build childrenRenderData - only shadow parent scope when element has own attrs + const hasOwnAttrs = Object.keys(resolved).length > 0 const childrenRenderData: NodeRenderData = hasOwnAttrs ? { ...this.renderData, props: resolved } : this.renderData if ((customComponent as StructuralComponent | undefined)?.ษตcomarkIf) { @@ -380,13 +373,7 @@ export class MarkdownNode implements OnChanges { } } else { const resolved = resolveAttributes(childProps, renderData, { parseJson: true }) - let hasOwnAttrs = false - for (const key in childProps) { - if (key !== '$') { - hasOwnAttrs = true - break - } - } + const hasOwnAttrs = Object.keys(resolved).length > 0 const childRenderData: NodeRenderData = hasOwnAttrs ? { ...renderData, props: resolved } : renderData this.renderNativeEl(parentEl, childTag, resolved, grandChildren, childRenderData, child) } diff --git a/packages/comark-angular/test/props-scope.test.ts b/packages/comark-angular/test/props-scope.test.ts deleted file mode 100644 index cc03b2c5..00000000 --- a/packages/comark-angular/test/props-scope.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import '@angular/compiler' -import { describe, expect, it } from 'vitest' -import { Component, provideZonelessChangeDetection, type Type } from '@angular/core' -import { bootstrapApplication } from '@angular/platform-browser' -import { renderApplication } from '@angular/platform-server' -import { parseMarkdown, type MarkdownDocument as MarkdownDocumentType } from 'comark' -import { MarkdownDocument } from '../src/components/markdown-document.component.ts' - -class Probe { - text = '' -} -Component({ - selector: 'app-probe', - standalone: true, - inputs: ['text'], - template: '[{{ text }}]', -})(Probe) - -async function renderMarkdown(markdown: string): Promise { - const document = await parseMarkdown(markdown) - const components: Record> = { probe: Probe as Type } - - class App { - document: MarkdownDocumentType = document - components = components - data = {} - } - Component({ - selector: 'app-root', - standalone: true, - imports: [MarkdownDocument], - template: ` - - `, - })(App) - - return renderApplication( - (context) => bootstrapApplication(App, { providers: [provideZonelessChangeDetection()] }, context), - { document: '' } - ) -} - -describe('MarkdownDocument: props scope when every attribute is omitted', () => { - it('hides the grandparent props from children of an element with all attributes omitted', async () => { - const html = await renderMarkdown( - '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}"}\n::probe{:text="props.label"}\n::\n:::\n::::' - ) - expect(html).toContain('class="probe"') - expect(html).not.toContain('[Outer]') - }) - - it('exposes the resolved attributes of the middle element to its children', async () => { - const html = await renderMarkdown( - '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}" id="keep"}\n::probe{:text="props.id"}\n::\n:::\n::::' - ) - expect(html).toContain('[keep]') - expect(html).not.toContain('[Outer]') - }) - - it('keeps the parent props visible through bare wrappers', async () => { - const html = await renderMarkdown('::::wrap{label="Outer"}\n- :probe{:text="props.label"}\n::::') - expect(html).toContain('

                    ') - expect(html).toContain('[Outer]') - }) -}) diff --git a/packages/comark-ansi/test/index.test.ts b/packages/comark-ansi/test/index.test.ts index 37b6d16b..c2af5615 100644 --- a/packages/comark-ansi/test/index.test.ts +++ b/packages/comark-ansi/test/index.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, it, expect, vi } from 'vitest' import { parseMarkdown } from 'comark' import shiki from 'comark/plugins/shiki' +import binding from 'comark/plugins/binding' import githubDark from 'shiki/dist/themes/github-dark.mjs' import { createAnsiPrinter, @@ -405,14 +406,14 @@ home: https://example.com }) it('interpolates placeholders in :href and :alt', async () => { - const link = await parseMarkdown('[Doc](x){:href="/d/{{ data.id }}"}') + const link = await parseMarkdown('[Doc](x){:href="/d/{{ data.id }}"}', { plugins: [binding()] }) expect(await renderAnsiFromDocument(link, { colors: false, data: { id: 7 } })).toContain('Doc (/d/7)') - const img = await parseMarkdown('![x](/x.png){:alt="Alt {{ data.id }}"}') + const img = await parseMarkdown('![x](/x.png){:alt="Alt {{ data.id }}"}', { plugins: [binding()] }) expect(await renderAnsiFromDocument(img, { colors: false, data: { id: 7 } })).toContain('[image: Alt 7]') }) it('prints the literal fallback when an interpolated URL is unsafe', async () => { - const link = await parseMarkdown('[Doc](x){:href="{{ data.bad }}"}') + const link = await parseMarkdown('[Doc](x){:href="{{ data.bad }}"}', { plugins: [binding()] }) const out = await renderAnsiFromDocument(link, { colors: false, data: { bad: 'javascript:alert(1)' } }) expect(out).toBe('Doc (x)\n') expect(out).not.toContain('javascript:') diff --git a/packages/comark-html/test/plugin-binding.test.ts b/packages/comark-html/test/plugin-binding.test.ts index dc1fea9f..239a2f4b 100644 --- a/packages/comark-html/test/plugin-binding.test.ts +++ b/packages/comark-html/test/plugin-binding.test.ts @@ -100,7 +100,7 @@ describe('@comark/html plugins/binding โ€” If handler', () => { it('interpolates `{{ }}` in If values to a truthy string even for false', async () => { const data = { off: false } - const interpolated = await parseMarkdown('::if{:value="{{ data.off }}"}\nShown\n::') + const interpolated = await parseWithBinding('::if{:value="{{ data.off }}"}\nShown\n::') expect(await renderHtmlFromDocument(interpolated, { components: { If }, data })).toBe('Shown') const plain = await parseMarkdown('::if{:value="data.off"}\nShown\n::') expect(await renderHtmlFromDocument(plain, { components: { If }, data })).toBe('') diff --git a/packages/comark-html/test/props-scope.test.ts b/packages/comark-html/test/props-scope.test.ts deleted file mode 100644 index b3f74749..00000000 --- a/packages/comark-html/test/props-scope.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseMarkdown } from 'comark' -import { renderHtmlFromDocument } from '../src/index' - -async function render(markdown: string, data: Record = {}): Promise { - return renderHtmlFromDocument(await parseMarkdown(markdown), { data }) -} - -describe('props scope when every attribute is omitted', () => { - it('does not leak the parent props onto a child whose bindings all resolve to nothing', async () => { - const html = await render('::card{title="T" id="p"}\n:span[x]{:data-x="{{ data.nope }}"}\n::') - expect(html).toContain('') - expect(html).toContain('x') - }) - - it('does not leak the parent props onto an element whose unsafe URL is omitted', async () => { - const html = await render('::card{title="T"}\n:a[x]{:href="{{ data.bad }}"}\n::', { bad: 'javascript:alert(1)' }) - expect(html).not.toContain('javascript:') - expect(html).toContain('x') - }) - - it('keeps the resolved attributes and still hides the parent props', async () => { - const html = await render('::card{title="T"}\n:span[x]{:data-x="{{ data.nope }}" id="keep"}\n::') - expect(html).toContain('x') - }) - - it('keeps the parent props visible through bare wrappers', async () => { - const html = await render('::card{title="T"}\n- :span[x]{:data-x="props.title"}\n::') - expect(html).toContain('
                      ') - expect(html).toContain('data-x="T"') - expect(html).not.toMatch(/<(ul|li)[^>]* title=/) - }) -}) diff --git a/packages/comark-html/test/string.test.ts b/packages/comark-html/test/string.test.ts index 969387e5..5c2aac0f 100644 --- a/packages/comark-html/test/string.test.ts +++ b/packages/comark-html/test/string.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { parseMarkdown } from 'comark' import { renderHtmlFromDocument } from '../src/index' +import binding from '../src/plugins/binding' describe('renderHtmlFromDocument', () => { it('renders without options (backward compatible)', async () => { @@ -208,19 +209,23 @@ name: Ada }) it('interpolates `{{ path }}` placeholders inside :prefixed props', async () => { - const doc = await parseMarkdown('Go: :a[Read more]{:href="/docs/{{ data.slug }}/overview"}') + const doc = await parseMarkdown('Go: :a[Read more]{:href="/docs/{{ data.slug }}/overview"}', { + plugins: [binding()], + }) const html = await renderHtmlFromDocument(doc, { data: { slug: 'getting-started' } }) expect(html).toContain('') }) it('interpolates placeholders in block YAML props', async () => { - const doc = await parseMarkdown('::card\n---\n:title: Item {{ data.id }}\n---\nBody\n::') + const doc = await parseMarkdown('::card\n---\n:title: Item {{ data.id }}\n---\nBody\n::', { + plugins: [binding()], + }) const html = await renderHtmlFromDocument(doc, { data: { id: 7 } }) expect(html).toContain('title="Item 7"') }) it('renders a link without href when the interpolated URL is unsafe', async () => { - const doc = await parseMarkdown(':a[x]{:href="{{ data.url }}"}') + const doc = await parseMarkdown(':a[x]{:href="{{ data.url }}"}', { plugins: [binding()] }) const bad = await renderHtmlFromDocument(doc, { data: { url: 'javascript:alert(1)' } }) expect(bad).toBe('x') const ok = await renderHtmlFromDocument(doc, { data: { url: '/ok' } }) @@ -228,7 +233,7 @@ name: Ada }) it('escapes interpolated values', async () => { - const doc = await parseMarkdown(':card{:title="T {{ data.t }}"}') + const doc = await parseMarkdown(':card{:title="T {{ data.t }}"}', { plugins: [binding()] }) const html = await renderHtmlFromDocument(doc, { data: { t: 'a"b`, `
                        `, `
                      • `, โ€ฆ) must keep the - // parent's scope so bindings like `{{ props.x }}` reach across them. The - // check uses the raw attributes: resolving can omit all of them. - let hasOwnAttrs = false - for (const key in nodeProps) { - if (key !== '$') { - hasOwnAttrs = true - break - } - } + // parent's scope so bindings like `{{ props.x }}` reach across them. + const hasOwnAttrs = Object.keys(resolved).length > 0 const childrenRenderData: NodeRenderData = hasOwnAttrs ? { ...renderData, props: scopeProps } : renderData // Separate template elements (slots) from regular children const slots: Record = {} diff --git a/packages/comark-react/test/props-scope.test.tsx b/packages/comark-react/test/props-scope.test.tsx deleted file mode 100644 index 519a7114..00000000 --- a/packages/comark-react/test/props-scope.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest' -import React from 'react' -import { renderToString } from 'react-dom/server' -import { parseMarkdown } from 'comark' -import { MarkdownDocument } from '../src/components/MarkdownDocument' - -function Probe({ text = '' }: { text?: string }) { - return {`[${text}]`} -} - -async function renderMarkdown(markdown: string, props: Record = {}) { - const tree = await parseMarkdown(markdown) - return renderToString( - - ) -} - -describe('MarkdownDocument: props scope when every attribute is omitted', () => { - it('hides the grandparent props from children of an element with all attributes omitted', async () => { - const html = await renderMarkdown( - '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}"}\n::probe{:text="props.label"}\n::\n:::\n::::', - { data: {} } - ) - expect(html).toContain('class="probe"') - expect(html).not.toContain('[Outer]') - }) - - it('exposes the resolved attributes of the middle element to its children', async () => { - const html = await renderMarkdown( - '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}" id="keep"}\n::probe{:text="props.id"}\n::\n:::\n::::', - { data: {} } - ) - expect(html).toContain('[keep]') - expect(html).not.toContain('[Outer]') - }) - - it('keeps the parent props visible through bare wrappers', async () => { - const html = await renderMarkdown('::::wrap{label="Outer"}\n- :probe{:text="props.label"}\n::::') - expect(html).toContain('
                          ') - expect(html).toContain('[Outer]') - }) -}) diff --git a/packages/comark-react/test/renderer-data-binding.test.tsx b/packages/comark-react/test/renderer-data-binding.test.tsx index 7f63c6fd..1bbcd5f6 100644 --- a/packages/comark-react/test/renderer-data-binding.test.tsx +++ b/packages/comark-react/test/renderer-data-binding.test.tsx @@ -3,6 +3,7 @@ import React from 'react' import { renderToString } from 'react-dom/server' import { parseMarkdown } from 'comark' import { MarkdownDocument } from '../src/components/MarkdownDocument' +import binding from '../src/plugins/binding' interface BadgeProps { label?: string @@ -37,8 +38,8 @@ function Card({ title = '', variant = '', children }: CardProps) { ) } -async function renderMarkdown(markdown: string, props: Record = {}) { - const tree = await parseMarkdown(markdown) +async function renderMarkdown(markdown: string, { plugins, ...props }: Record = {}) { + const tree = await parseMarkdown(markdown, { plugins }) return renderToString( { const md = ':a[x]{:href="{{ data.url }}"}' - const bad = await renderMarkdown(md, { data: { url: 'javascript:alert(1)' } }) + const bad = await renderMarkdown(md, { data: { url: 'javascript:alert(1)' }, plugins: [binding()] }) expect(bad).not.toContain('javascript:') expect(bad).not.toContain('href') expect(bad).toContain('x') - const ok = await renderMarkdown(md, { data: { url: '/ok' } }) + const ok = await renderMarkdown(md, { data: { url: '/ok' }, plugins: [binding()] }) expect(ok).toContain('href="/ok"') }) diff --git a/packages/comark-svelte/src/components/MarkdownNode.svelte b/packages/comark-svelte/src/components/MarkdownNode.svelte index 67d1a3a0..2bc3776d 100644 --- a/packages/comark-svelte/src/components/MarkdownNode.svelte +++ b/packages/comark-svelte/src/components/MarkdownNode.svelte @@ -191,20 +191,11 @@ naturally appears inline after the deepest trailing text node. // Only shadow the parent's `props` scope when the current element has its // own attributes. Bare wrappers (`

                          `, `

                            `, `
                          • `, โ€ฆ) must keep the - // parent's scope so bindings like `{{ props.x }}` reach across them. The - // check uses the raw attributes: resolving can omit all of them. + // parent's scope so bindings like `{{ props.x }}` reach across them. let nativeProps = $derived(tag ? toNativeAttributes(tag, node, mappedProps) : mappedProps) - let hasOwnAttrs = $derived.by(() => { - const attrs: Record = (Array.isArray(node) && node.length >= 2 ? node[1] : null) ?? {} - for (const key in attrs) { - if (key !== '$') return true - } - return false - }) - let childrenRenderData = $derived( - hasOwnAttrs + Object.keys(mappedProps).length > 0 ? { ...renderData, props: mappedProps } : renderData, ) diff --git a/packages/comark-svelte/test/plugin-binding.test.ts b/packages/comark-svelte/test/plugin-binding.test.ts index 14541f22..0d09e7aa 100644 --- a/packages/comark-svelte/test/plugin-binding.test.ts +++ b/packages/comark-svelte/test/plugin-binding.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { render } from 'svelte/server' import { parseMarkdown } from 'comark' import MarkdownDocument from '../src/components/MarkdownDocument.svelte' -import { If } from '../src/plugins/binding' +import binding, { If } from '../src/plugins/binding' import { nestedIfCases, nestedIfMarkdown } from '../../../test/fixtures/if' describe('@comark/svelte plugins/binding โ€” If SSR', () => { @@ -15,13 +15,13 @@ describe('@comark/svelte plugins/binding โ€” If SSR', () => { describe('@comark/svelte prop interpolation SSR', () => { it('resolves `{{ path }}` placeholders in :href', async () => { - const value = await parseMarkdown('Go :a[Docs]{:href="/d/{{ data.id }}"}') + const value = await parseMarkdown('Go :a[Docs]{:href="/d/{{ data.id }}"}', { plugins: [binding()] }) const { body } = render(MarkdownDocument, { props: { value, data: { id: 7 } } }) expect(body).toContain('href="/d/7"') }) it('omits a rejected URL while keeping the link text', async () => { - const value = await parseMarkdown(':a[x]{:href="{{ data.url }}"}') + const value = await parseMarkdown(':a[x]{:href="{{ data.url }}"}', { plugins: [binding()] }) const bad = render(MarkdownDocument, { props: { value, data: { url: 'javascript:alert(1)' } } }).body expect(bad).not.toContain('javascript:') expect(bad).not.toContain('href') diff --git a/packages/comark-svelte/test/props-scope.test.ts b/packages/comark-svelte/test/props-scope.test.ts deleted file mode 100644 index 8ce7c106..00000000 --- a/packages/comark-svelte/test/props-scope.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { render } from 'svelte/server' -import { parseMarkdown } from 'comark' -import MarkdownDocument from '../src/components/MarkdownDocument.svelte' -import Probe from './test-components/Probe.svelte' - -async function renderMarkdown(markdown: string, data: Record = {}) { - const value = await parseMarkdown(markdown) - return render(MarkdownDocument, { props: { value, components: { probe: Probe }, data } }).body -} - -describe('MarkdownDocument: props scope when every attribute is omitted', () => { - it('hides the grandparent props from children of an element with all attributes omitted', async () => { - const html = await renderMarkdown( - '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}"}\n::probe{:text="props.label"}\n::\n:::\n::::' - ) - expect(html).toContain('class="probe"') - expect(html).not.toContain('[Outer]') - }) - - it('exposes the resolved attributes of the middle element to its children', async () => { - const html = await renderMarkdown( - '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}" id="keep"}\n::probe{:text="props.id"}\n::\n:::\n::::' - ) - expect(html).toContain('[keep]') - expect(html).not.toContain('[Outer]') - }) - - it('keeps the parent props visible through bare wrappers', async () => { - const html = await renderMarkdown('::::wrap{label="Outer"}\n- :probe{:text="props.label"}\n::::') - expect(html).toContain('
                              ') - expect(html).toContain('[Outer]') - }) -}) diff --git a/packages/comark-svelte/test/test-components/Probe.svelte b/packages/comark-svelte/test/test-components/Probe.svelte deleted file mode 100644 index d4b3434a..00000000 --- a/packages/comark-svelte/test/test-components/Probe.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - -[{text}] diff --git a/packages/comark-vue/src/components/MarkdownDocument.ts b/packages/comark-vue/src/components/MarkdownDocument.ts index 127cfdf0..1201fdd8 100644 --- a/packages/comark-vue/src/components/MarkdownDocument.ts +++ b/packages/comark-vue/src/components/MarkdownDocument.ts @@ -171,15 +171,8 @@ function renderNode( // Only shadow the parent's `props` scope when the current element has its // own attributes. Bare wrappers (`

                              `, `

                                `, `
                              • `, โ€ฆ) must keep the - // parent's scope so bindings like `{{ props.x }}` reach across them. The - // check uses the raw attributes: resolving can omit all of them. - let hasOwnAttrs = false - for (const key in nodeProps) { - if (key !== '$') { - hasOwnAttrs = true - break - } - } + // parent's scope so bindings like `{{ props.x }}` reach across them. + const hasOwnAttrs = Object.keys(resolved).length > 0 const childrenRenderData = hasOwnAttrs ? { ...renderData, props: scopeProps } : renderData // Separate template elements (slots) from regular children const slots: Record (VNode | string)[]> = {} diff --git a/packages/comark-vue/test/interpolation-reactivity.test.ts b/packages/comark-vue/test/interpolation-reactivity.test.ts index d6cde84a..2726b347 100644 --- a/packages/comark-vue/test/interpolation-reactivity.test.ts +++ b/packages/comark-vue/test/interpolation-reactivity.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Suspense, createRenderer, h, nextTick, reactive } from 'vue' import { parseMarkdown } from 'comark' import { MarkdownDocument } from '../src/components/MarkdownDocument' +import binding from '../src/plugins/binding' interface N { props: Record @@ -45,7 +46,7 @@ const settle = async () => { } async function mount(markdown: string, data: Record): Promise { - const value = await parseMarkdown(markdown) + const value = await parseMarkdown(markdown, { plugins: [binding()] }) const root = node() createApp({ render: () => h(Suspense, null, { default: () => h(MarkdownDocument, { value, data }) }) }).mount(root) await settle() diff --git a/packages/comark-vue/test/props-scope.test.ts b/packages/comark-vue/test/props-scope.test.ts deleted file mode 100644 index 555e958d..00000000 --- a/packages/comark-vue/test/props-scope.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createSSRApp, defineComponent, h } from 'vue' -import { renderToString } from '@vue/server-renderer' -import { parseMarkdown } from 'comark' -import { MarkdownDocument } from '../src/components/MarkdownDocument' - -const Probe = defineComponent({ - props: { text: { type: String, default: '' } }, - setup(props) { - return () => h('i', { class: 'probe' }, `[${props.text}]`) - }, -}) - -async function renderMarkdown(markdown: string, props: Record = {}) { - const tree = await parseMarkdown(markdown) - const app = createSSRApp({ - setup: () => () => h(MarkdownDocument, { value: tree, components: { probe: Probe }, ...props }), - }) - return renderToString(app as any) -} - -describe('MarkdownDocument: props scope when every attribute is omitted', () => { - it('hides the grandparent props from children of an element with all attributes omitted', async () => { - const html = await renderMarkdown( - '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}"}\n::probe{:text="props.label"}\n::\n:::\n::::', - { data: {} } - ) - expect(html).toContain('class="probe"') - expect(html).not.toContain('[Outer]') - }) - - it('exposes the resolved attributes of the middle element to its children', async () => { - const html = await renderMarkdown( - '::::wrap{label="Outer"}\n:::div{:data-x="{{ data.nope }}" id="keep"}\n::probe{:text="props.id"}\n::\n:::\n::::', - { data: {} } - ) - expect(html).toContain('[keep]') - expect(html).not.toContain('[Outer]') - }) - - it('keeps the parent props visible through bare wrappers', async () => { - const html = await renderMarkdown('::::wrap{label="Outer"}\n- :probe{:text="props.label"}\n::::') - expect(html).toContain('
                                  ') - expect(html).toContain('[Outer]') - }) -}) diff --git a/packages/comark-vue/test/renderer-data-binding.test.ts b/packages/comark-vue/test/renderer-data-binding.test.ts index c6eae43e..46fe9490 100644 --- a/packages/comark-vue/test/renderer-data-binding.test.ts +++ b/packages/comark-vue/test/renderer-data-binding.test.ts @@ -3,6 +3,7 @@ import { createSSRApp, defineComponent, h } from 'vue' import { renderToString } from '@vue/server-renderer' import { parseMarkdown } from 'comark' import { MarkdownDocument } from '../src/components/MarkdownDocument' +import binding from '../src/plugins/binding' const Badge = defineComponent({ name: 'Badge', @@ -39,8 +40,8 @@ const VariantCard = defineComponent({ }, }) -async function renderMarkdown(markdown: string, props: Record = {}) { - const tree = await parseMarkdown(markdown) +async function renderMarkdown(markdown: string, { plugins, ...props }: Record = {}) { + const tree = await parseMarkdown(markdown, { plugins }) const app = createSSRApp({ setup: () => () => h(MarkdownDocument, { value: tree, ...props }), }) @@ -125,7 +126,7 @@ site: :: :a[Docs]{:href="/d/{{ data.id }}"}`, - { components: { badge: Badge }, data: { name: 'Ada', id: 7 } } + { components: { badge: Badge }, data: { name: 'Ada', id: 7 }, plugins: [binding()] } ) expect(html).toContain('Hi Ada') expect(html).toContain('href="/d/7"') @@ -133,17 +134,18 @@ site: it('omits a rejected URL while keeping the link text', async () => { const md = ':a[x]{:href="{{ data.url }}"}' - const bad = await renderMarkdown(md, { data: { url: 'javascript:alert(1)' } }) + const bad = await renderMarkdown(md, { data: { url: 'javascript:alert(1)' }, plugins: [binding()] }) expect(bad).not.toContain('javascript:') expect(bad).not.toContain('href') expect(bad).toContain('x') - const ok = await renderMarkdown(md, { data: { url: '/ok' } }) + const ok = await renderMarkdown(md, { data: { url: '/ok' }, plugins: [binding()] }) expect(ok).toContain('href="/ok"') }) it('omits a prop whose sole placeholder is unresolved so the default applies', async () => { const html = await renderMarkdown(`::card{:variant="{{ data.missing }}"}\n::`, { components: { card: VariantCard }, + plugins: [binding()], }) expect(html).toContain('class="card primary"') }) diff --git a/packages/comark/SPEC/COMARK/data-binding-interpolation-disabled.md b/packages/comark/SPEC/COMARK/data-binding-interpolation-disabled.md new file mode 100644 index 00000000..89a93826 --- /dev/null +++ b/packages/comark/SPEC/COMARK/data-binding-interpolation-disabled.md @@ -0,0 +1,50 @@ +## Input + +```md +--- +id: 7 +slug: getting-started +--- + +::card{:title="Item {{ frontmatter.id }}" :link="/docs/{{ frontmatter.slug }}"} +:: +``` + +## AST + +```json +{ + "frontmatter": { + "id": 7, + "slug": "getting-started" + }, + "meta": {}, + "nodes": [ + [ + "card", + { + ":title": "Item {{ frontmatter.id }}", + ":link": "/docs/{{ frontmatter.slug }}" + } + ] + ] +} +``` + +## HTML + +```html + +``` + +## Markdown + +```md +--- +id: 7 +slug: getting-started +--- + +::card{:title="Item {{ frontmatter.id }}" :link="/docs/{{ frontmatter.slug }}"} +:: +``` diff --git a/packages/comark/SPEC/COMARK/data-binding-interpolation.md b/packages/comark/SPEC/COMARK/data-binding-interpolation.md index f9b0744e..fe3bbde8 100644 --- a/packages/comark/SPEC/COMARK/data-binding-interpolation.md +++ b/packages/comark/SPEC/COMARK/data-binding-interpolation.md @@ -1,3 +1,9 @@ +--- +options: + plugins: + - binding +--- + ## Input ```md @@ -32,14 +38,20 @@ slug: getting-started { ":title": "Item {{ frontmatter.id }}", ":link": "/docs/{{ frontmatter.slug }}/{{ frontmatter.missing || overview }}", - ":note": "{{ frontmatter.missing }}" + ":note": "{{ frontmatter.missing }}", + "$": { + "bind": 1 + } } ], [ "card", { ":title": "Item {{ frontmatter.id }}", - ":to": "javascript:{{ frontmatter.slug }}" + ":to": "javascript:{{ frontmatter.slug }}", + "$": { + "bind": 1 + } } ] ] diff --git a/packages/comark/src/internal/props-validation.ts b/packages/comark/src/internal/props-validation.ts index bbe64dcf..07e544eb 100644 --- a/packages/comark/src/internal/props-validation.ts +++ b/packages/comark/src/internal/props-validation.ts @@ -1,3 +1,5 @@ +import { parseTemplate } from './template.ts' + /** * Sentinel returned by {@link validateProp} to signal "drop this attribute". * A dedicated symbol (rather than `false`) keeps rejection distinguishable @@ -236,7 +238,7 @@ const URL_PROP_SUFFIX = /(?:href|src|url|uri|link)s?$/ * `title="JavaScript: the good parts"` intact: it parses as a `javascript:` * URL, but is never navigated to. */ -function isUrlProp(name: string): boolean { +export function isUrlProp(name: string): boolean { return URL_PROPS.has(name) || URL_PROP_SUFFIX.test(name) } @@ -246,7 +248,7 @@ function isUrlProp(name: string): boolean { * prefixes (`.innerHTML`, `^onclick`), and `:` / `-` / `_` separators * (`xlink:href`, `label-html`). `@x` / `v-on:x` become `onx`. */ -function normalizePropName(attribute: string): string { +export function normalizePropName(attribute: string): string { return attribute .toLowerCase() .replace(/^(?:v-bind:|[:.^])+/, '') @@ -277,6 +279,11 @@ export function validateProp(attribute: string, value: unknown, options: PropsVa const result = validateValue(name, decoded, options) return result === REJECTED_PROP ? REJECTED_PROP : JSON.stringify(result) } + // A `{{ }}` template in a URL prop is only known at render time, so check what the literal text fixes now + if (decoded === value && isUrlProp(name)) { + const parts = parseTemplate(value) + if (parts) return validateUrlTemplate(name, parts[0] as string, options) === REJECTED_PROP ? REJECTED_PROP : value + } const result = validateValue(name, typeof decoded === 'string' ? decoded : value, options) // Keep the original value so bindings still resolve at render time. The // defaultOrigin rewrite only makes sense for literal URLs. @@ -286,6 +293,35 @@ export function validateProp(attribute: string, value: unknown, options: PropsVa return validateValue(name, value, options) } +const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i +const DOUBLE_SLASH = /^[/\\]{2}/ +const PATH_START = /[/\\?#]/ + +/** + * Validate a URL template by its literal prefix, the text before the first placeholder. + * Data can only fill what follows, so the prefix must fix the scheme and host on its own: + * `/docs/{{ x }}` and `https://myapp.com/{{ x }}` pass, while `java{{ x }}`, `/{{ x }}` and + * `https://myapp.com{{ x }}` are rejected. A template that starts with a placeholder is + * as trusted as a plain `:href="data.url"` binding. + */ +function validateUrlTemplate(name: string, prefix: string, options: PropsValidationOptions): unknown { + const literal = prefix.trimStart() + if (literal === '') return prefix + + const scheme = URL_SCHEME.exec(literal) + const rest = scheme ? literal.slice(scheme[0].length) : literal + if (DOUBLE_SLASH.test(rest)) { + // `https://hostโ€ฆ` or `//hostโ€ฆ`: the host must end before the data + if (!PATH_START.test(rest.slice(2))) return REJECTED_PROP + } else if (!scheme) { + // A relative prefix must already be a path, query or fragment, and `/` alone would let data add a host + if (!PATH_START.test(literal) || literal === '/' || literal === '\\') return REJECTED_PROP + } + + // Allowlists apply to the prefix. A prefix that would need a `defaultOrigin` rewrite is not allowed. + return validateValue(name, literal, options) === literal ? prefix : REJECTED_PROP +} + function validateValue(name: string, value: unknown, options: PropsValidationOptions): unknown { if (typeof value === 'string') { // SVG `` / `` targeting `href` can rewrite a link to the diff --git a/packages/comark/src/internal/stringify/attributes.ts b/packages/comark/src/internal/stringify/attributes.ts index 6a6eafa7..f8e9d0b3 100644 --- a/packages/comark/src/internal/stringify/attributes.ts +++ b/packages/comark/src/internal/stringify/attributes.ts @@ -3,7 +3,7 @@ import { escapeHtml, get } from '../../utils/index.ts' import { isUnsafeUrlValue } from '../props-validation.ts' import { pickFence } from './fence.ts' import { interpolateProp } from './interpolate.ts' -import type { NodeRenderData } from '../../types.ts' +import type { ElementNodeAttributes, NodeRenderData } from '../../types.ts' export interface ResolveAttributesOptions { /** @@ -41,8 +41,9 @@ const HTML_SINK_PROPS = new Set(['innerhtml', 'dangerouslysetinnerhtml', 'textco * the `:` prefix is always stripped, falling back to the dot-path lookup. * The `$` metadata key is never forwarded. * - * A `:prefixed` string with `{{ path || default }}` placeholders is interpolated - * in both modes. The prop is omitted when nothing is available or the result is unsafe. + * On a node marked by the `binding()` plugin (`$.bind`), a `:prefixed` string + * with `{{ path || default }}` placeholders is interpolated in both modes. + * The prop is omitted when nothing is available or the result is unsafe. */ export function resolveAttributes( attrs: Record, @@ -50,6 +51,7 @@ export function resolveAttributes( options: ResolveAttributesOptions = {} ): Record { const result: Record = {} + const bind = (attrs.$ as ElementNodeAttributes['$'])?.bind === 1 for (const key in attrs) { if (key === '$') continue @@ -61,19 +63,16 @@ export function resolveAttributes( if (HTML_SINK_PROPS.has(lowerOutKey)) continue - if (isBinding && typeof value === 'string') { - const text = interpolateProp(outKey, value, renderData) - if (text !== undefined) { - // UNRESOLVED and REJECTED_PROP are symbols: the prop is omitted - if (typeof text === 'string') result[outKey] = text - continue - } - } - let outValue: unknown let resultKey = key + const text = bind && isBinding && typeof value === 'string' ? interpolateProp(outKey, value, renderData) : undefined - if (options.parseJson && isBinding) { + if (text !== undefined) { + // UNRESOLVED and REJECTED_PROP are symbols: the prop is omitted + if (typeof text !== 'string') continue + outValue = text + resultKey = outKey + } else if (options.parseJson && isBinding) { // Framework mode: always strip `:` and hand components real JS values. if (typeof value === 'string') { try { @@ -121,16 +120,18 @@ export function resolveAttributes( /** * Read a named attribute, preferring its `:prefixed` binding (resolved against * `renderData`) over the literal `key`. Falls back to the raw value when the - * binding doesn't resolve. + * binding doesn't resolve. An interpolated template that must be omitted + * falls back to the literal `key`, as `resolveAttributes` keeps it. */ export function resolveAttribute(attrs: Record, renderData: NodeRenderData, key: string): unknown { const bindKey = `:${key}` if (bindKey in attrs) { const value = attrs[bindKey] if (typeof value === 'string') { - const text = interpolateProp(key, value, renderData) - if (typeof text === 'string') return text - if (text !== undefined) return attrs[key] + if ((attrs.$ as ElementNodeAttributes['$'])?.bind === 1) { + const text = interpolateProp(key, value, renderData) + if (text !== undefined) return typeof text === 'string' ? text : attrs[key] + } const resolved = get(renderData, value) if (resolved !== undefined) return resolved } @@ -209,6 +210,8 @@ export function userBlockAttrs(tag: string, attributes: Record) */ export function comarkAttributes(attributes: Record) { const attrs = Object.entries(attributes) + // `$` is parser metadata (line, html, bind), never an authored attribute + .filter(([key]) => key !== '$') .map(([key, value]) => { if (key.startsWith(':') && value === 'true') { return key.slice(1) diff --git a/packages/comark/src/internal/stringify/interpolate.ts b/packages/comark/src/internal/stringify/interpolate.ts index 8de906d1..8630984d 100644 --- a/packages/comark/src/internal/stringify/interpolate.ts +++ b/packages/comark/src/internal/stringify/interpolate.ts @@ -1,52 +1,19 @@ import { get } from '../../utils/index.ts' -import { REJECTED_PROP, validateProp } from '../props-validation.ts' +import { REJECTED_PROP, isUrlProp, normalizePropName, validateProp } from '../props-validation.ts' +import { MAX_TEMPLATE_LENGTH, parseTemplate } from '../template.ts' import type { NodeRenderData } from '../../types.ts' /** Returned when the prop must be omitted because no value is available. */ export const UNRESOLVED = Symbol('comark:unresolved-template') -const MAX_LENGTH = 4096 -/** The render context namespaces. Other roots stay as written. */ -const ROOTS = new Set(['frontmatter', 'meta', 'data', 'props']) -/** Names that would reach the prototype chain. */ -const BLOCKED_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']) /** Props that are not plain text, or that choose the rendered element. */ const RESERVED_NAMES = new Set(['key', 'ref', 'is', 'as', 'style']) -const BAD_PATH_CHARS = /[\s{}[\]|'"]/ /** Control and bidi override characters that must never reach a prop. */ // oxlint-disable-next-line no-control-regex -const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u202A-\u202E\u2066-\u2069]/g - -/** One `{{ path || fallback }}` placeholder. */ -interface Placeholder { - path: string - fallback?: string -} - -/** Returns `undefined` when malformed, so the whole value stays as written. */ -function parsePlaceholder(inner: string): Placeholder | undefined { - const split = inner.indexOf('||') - const path = (split < 0 ? inner : inner.slice(0, split)).trim() - const segments = path.split('.') - if (!ROOTS.has(segments[0]!) || BAD_PATH_CHARS.test(path)) return undefined - if (segments.some((s) => s === '' || BLOCKED_SEGMENTS.has(s))) return undefined - return split < 0 ? { path } : { path, fallback: inner.slice(split + 2).trim() } -} - -/** Alternates text and placeholders, so the result always starts and ends with text. */ -function parseTemplate(value: string): Array | undefined { - const parts: Array = [] - let pos = 0 - for (let open = value.indexOf('{{'); open >= 0; open = value.indexOf('{{', pos)) { - const close = value.indexOf('}}', open + 2) - const placeholder = close < 0 ? undefined : parsePlaceholder(value.slice(open + 2, close)) - if (!placeholder) return undefined - parts.push(value.slice(pos, open), placeholder) - pos = close + 2 - } - parts.push(value.slice(pos)) - return parts -} +const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009Fโ€ช-โ€ฎโฆ-โฉ]/g +/** A URL that browsers resolve against another host (`//host`, `/\host`), after the leading whitespace they strip. */ +// oxlint-disable-next-line no-control-regex +const SCHEME_RELATIVE = /^[\u0000- ]*[/\\]{2}/ /** Only text, numbers and booleans count. A throwing getter counts as missing. */ function lookup(renderData: NodeRenderData, path: string): string | undefined { @@ -61,6 +28,7 @@ function lookup(renderData: NodeRenderData, path: string): string | undefined { /** * Resolve `{{ path || default }}` placeholders in the value of a `:prop`. + * Only called for nodes marked by the `binding()` plugin. * Returns `undefined` when the value is not a template, so the caller keeps its usual behavior. * Returns `UNRESOLVED` when the prop must be omitted, and `REJECTED_PROP` when the result is unsafe. * Inserted text is never scanned again, so data cannot inject placeholders. @@ -71,8 +39,7 @@ export function interpolateProp( renderData: NodeRenderData ): string | typeof UNRESOLVED | typeof REJECTED_PROP | undefined { // A key such as `::title` is a nested binding, so leave it alone - if (value.length > MAX_LENGTH || !value.includes('{{') || name.startsWith(':')) return undefined - if (RESERVED_NAMES.has(name.toLowerCase().replace(/^[.^]/, ''))) return undefined + if (name.startsWith(':') || RESERVED_NAMES.has(name.toLowerCase().replace(/^[.^]/, ''))) return undefined // A JSON string literal is the escape hatch that keeps the plain binding behavior if (value[0] === '"') { try { @@ -95,11 +62,23 @@ export function interpolateProp( // A sole placeholder has no text around it, so omitting the prop is safer than an empty value if (found === undefined && sole) return UNRESOLVED // Skip cleaning a huge value, because it can only exceed the limit - if (found !== undefined && found.length > MAX_LENGTH) return UNRESOLVED + if (found !== undefined && found.length > MAX_TEMPLATE_LENGTH) return UNRESOLVED // Line breaks become a space, other control characters are removed output += (found ?? '').replace(/[\t\r\n]+/g, ' ').replace(CONTROL_CHARS, '') } - if (output.length > MAX_LENGTH) return UNRESOLVED + if (output.length > MAX_TEMPLATE_LENGTH) return UNRESOLVED + } + + // Literal text must not join with data into a link to another host: `/{{ x }}` with `x = "/evil.com"`. + // A value that starts with data is as trusted as a plain `:href="data.url"` binding. + const prefix = parts[0] as string + if ( + prefix !== '' && + !SCHEME_RELATIVE.test(prefix) && + SCHEME_RELATIVE.test(output) && + isUrlProp(normalizePropName(name)) + ) { + return REJECTED_PROP } // Check the final text, so a scheme split across pieces is caught diff --git a/packages/comark/src/internal/stringify/state.ts b/packages/comark/src/internal/stringify/state.ts index 1cad594e..a18612d4 100644 --- a/packages/comark/src/internal/stringify/state.ts +++ b/packages/comark/src/internal/stringify/state.ts @@ -58,22 +58,12 @@ export async function one(node: Node, state: State, parent?: ElementNode, atLine // element's values, regardless of which handler (html / ansi / user) runs. // Elements with no attributes (e.g. an auto-generated `

                                  ` wrapper) must // NOT shadow the parent's scope, otherwise `{{ props.* }}` inside them would - // resolve to nothing. The check uses the raw attributes, because resolving - // can drop all of them and the parent's props must not leak back in. + // resolve to nothing. const prevRenderData = state.renderData if (state.renderData && node[1]) { - let hasOwnAttrs = false - for (const key in node[1]) { - if (key !== '$') { - hasOwnAttrs = true - break - } - } - if (hasOwnAttrs) { - state.renderData = { - ...prevRenderData, - props: resolveAttributes(node[1] as Record, prevRenderData), - } + const resolved = resolveAttributes(node[1] as Record, prevRenderData) + if (Object.keys(resolved).length > 0) { + state.renderData = { ...prevRenderData, props: resolved } } } diff --git a/packages/comark/src/internal/template.ts b/packages/comark/src/internal/template.ts new file mode 100644 index 00000000..e484948c --- /dev/null +++ b/packages/comark/src/internal/template.ts @@ -0,0 +1,49 @@ +/** Longest `:prop` value scanned for placeholders, and longest interpolated result. */ +export const MAX_TEMPLATE_LENGTH = 4096 + +/** The render context namespaces. Other roots stay as written. */ +const ROOTS = new Set(['frontmatter', 'meta', 'data', 'props']) +/** Names that would reach the prototype chain. */ +const BLOCKED_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']) +const BAD_PATH_CHARS = /[\s{}[\]|'"]/ + +/** One `{{ path || fallback }}` placeholder. */ +export interface Placeholder { + path: string + fallback?: string +} + +/** Returns `undefined` when malformed, so the whole value stays as written. */ +function parsePlaceholder(inner: string): Placeholder | undefined { + const split = inner.indexOf('||') + const path = (split < 0 ? inner : inner.slice(0, split)).trim() + const segments = path.split('.') + if (!ROOTS.has(segments[0]!) || BAD_PATH_CHARS.test(path)) return undefined + if (segments.some((s) => s === '' || BLOCKED_SEGMENTS.has(s))) return undefined + return split < 0 ? { path } : { path, fallback: inner.slice(split + 2).trim() } +} + +/** + * Split a `:prop` value into text and `{{ path || default }}` placeholders. + * The result alternates text and placeholders, so it always starts and ends with text. + * Returns `undefined` when the value has no placeholder or any placeholder is malformed. + */ +export function parseTemplate(value: string): Array | undefined { + if (value.length > MAX_TEMPLATE_LENGTH || !value.includes('{{')) return undefined + const parts: Array = [] + let pos = 0 + for (let open = value.indexOf('{{'); open >= 0; open = value.indexOf('{{', pos)) { + const close = value.indexOf('}}', open + 2) + const placeholder = close < 0 ? undefined : parsePlaceholder(value.slice(open + 2, close)) + if (!placeholder) return undefined + parts.push(value.slice(pos, open), placeholder) + pos = close + 2 + } + parts.push(value.slice(pos)) + return parts +} + +/** Whether a value is a well-formed template, i.e. `binding()` interpolates it. */ +export function hasTemplate(value: string): boolean { + return parseTemplate(value) !== undefined +} diff --git a/packages/comark/src/plugins/binding.ts b/packages/comark/src/plugins/binding.ts index 81992274..433f54e9 100644 --- a/packages/comark/src/plugins/binding.ts +++ b/packages/comark/src/plugins/binding.ts @@ -1,5 +1,6 @@ import type { PluginWithOptions, MarkdownExit } from 'markdown-exit' import { defineComarkPlugin } from 'comark' +import { hasTemplate } from '../internal/template.ts' import type { MarkdownItPlugin, NodeHandler, Node } from 'comark' export interface MdcInlineBindingOptions { @@ -141,10 +142,35 @@ const markdownItInlineBinding: PluginWithOptions = (md, }) } +/** + * Mark elements whose `:` attributes hold a well-formed `{{ }}` template, so the + * renderers interpolate them. Without the plugin, those values stay as written. + * The walk uses its own stack, so deep trees are neither truncated nor overflow. + */ +function markTemplateAttributes(nodes: Node[]): void { + const stack: Node[] = [...nodes] + while (stack.length > 0) { + const node = stack.pop()! + if (!Array.isArray(node) || node[0] === null) continue + const attrs = node[1] + for (const key in attrs) { + const value = attrs[key] + if (key.charCodeAt(0) === 58 /* ':' */ && typeof value === 'string' && hasTemplate(value)) { + attrs.$ = { ...attrs.$, bind: 1 } + break + } + } + for (let i = node.length - 1; i >= 2; i--) stack.push(node[i] as Node) + } +} + export default defineComarkPlugin((opts: MdcInlineBindingOptions = {}) => { return { name: 'binding', markdownItPlugins: [((md: MarkdownExit) => markdownItInlineBinding(md, opts)) as unknown as MarkdownItPlugin], + post(state) { + markTemplateAttributes(state.tree.nodes) + }, } }) diff --git a/packages/comark/src/types.ts b/packages/comark/src/types.ts index 162f1234..78a7d1cf 100644 --- a/packages/comark/src/types.ts +++ b/packages/comark/src/types.ts @@ -29,6 +29,8 @@ export type ElementNodeAttributes = { line?: number html?: 0 | 1 block?: 0 | 1 + /** `:` attributes on this node hold `{{ }}` templates to interpolate. Set by the `binding()` plugin. */ + bind?: 1 } } diff --git a/packages/comark/test/dos.test.ts b/packages/comark/test/dos.test.ts index 69a085e8..854bd83f 100644 --- a/packages/comark/test/dos.test.ts +++ b/packages/comark/test/dos.test.ts @@ -64,7 +64,7 @@ describe('denial-of-service hardening', () => { for (const { label, options } of modes) { it(`resolves hundreds of placeholders in one template quickly in ${label}`, () => { const start = performance.now() - const result = resolveAttributes({ ':title': '{{ data.x }}'.repeat(340) }, renderData, options) + const result = resolveAttributes({ $: { bind: 1 }, ':title': '{{ data.x }}'.repeat(340) }, renderData, options) expect(result).toEqual({ title: 'v'.repeat(340) }) expect(performance.now() - start).toBeLessThan(500) }) @@ -72,7 +72,7 @@ describe('denial-of-service hardening', () => { it(`resolves a long default value quickly in ${label}`, () => { const start = performance.now() const result = resolveAttributes( - { ':title': '{{ data.missing || ' + 'a'.repeat(3900) + ' }}' }, + { $: { bind: 1 }, ':title': '{{ data.missing || ' + 'a'.repeat(3900) + ' }}' }, renderData, options ) @@ -84,14 +84,16 @@ describe('denial-of-service hardening', () => { it('keeps a flood of unclosed `{{` as written without rescanning', () => { const value = '{{ '.repeat(1_300) const start = performance.now() - const result = resolveAttributes({ ':title': value }, renderData) + const result = resolveAttributes({ $: { bind: 1 }, ':title': value }, renderData) expect(result).toEqual({ ':title': value }) expect(performance.now() - start).toBeLessThan(200) }) it('drops a flood of unclosed `{{` in parseJson mode without rescanning', () => { const start = performance.now() - const result = resolveAttributes({ ':title': '{{ '.repeat(1_300) }, renderData, { parseJson: true }) + const result = resolveAttributes({ $: { bind: 1 }, ':title': '{{ '.repeat(1_300) }, renderData, { + parseJson: true, + }) expect(result).toHaveProperty('title', undefined) expect(Object.keys(result)).toEqual(['title']) expect(performance.now() - start).toBeLessThan(200) @@ -100,14 +102,16 @@ describe('denial-of-service hardening', () => { it('keeps a closed placeholder followed by unclosed `{{` as written', () => { const value = '{{ data.x }}{{ '.repeat(250) const start = performance.now() - const result = resolveAttributes({ ':title': value }, renderData) + const result = resolveAttributes({ $: { bind: 1 }, ':title': value }, renderData) expect(result).toEqual({ ':title': value }) expect(performance.now() - start).toBeLessThan(200) }) it('drops a closed placeholder followed by unclosed `{{` in parseJson mode', () => { const start = performance.now() - const result = resolveAttributes({ ':title': '{{ data.x }}{{ '.repeat(250) }, renderData, { parseJson: true }) + const result = resolveAttributes({ $: { bind: 1 }, ':title': '{{ data.x }}{{ '.repeat(250) }, renderData, { + parseJson: true, + }) expect(result).toHaveProperty('title', undefined) expect(Object.keys(result)).toEqual(['title']) expect(performance.now() - start).toBeLessThan(200) @@ -115,7 +119,7 @@ describe('denial-of-service hardening', () => { it('leaves a template over the length limit as written', () => { const value = '{{ data.x }}'.repeat(500) - const result = resolveAttributes({ ':title': value }, renderData) + const result = resolveAttributes({ $: { bind: 1 }, ':title': value }, renderData) expect(result).toEqual({ ':title': value }) }) }) diff --git a/packages/comark/test/plugins/binding.test.ts b/packages/comark/test/plugins/binding.test.ts index 6e938994..e5a7d4e5 100644 --- a/packages/comark/test/plugins/binding.test.ts +++ b/packages/comark/test/plugins/binding.test.ts @@ -182,3 +182,69 @@ Intro text. expect(html).not.toContain('

                                  { + const bindOf = (node: unknown) => (node as [string, { $?: { bind?: 1 } }])[1].$?.bind + + it('marks inline attributes, YAML props and raw HTML attributes', async () => { + const inline = await parseWithBinding('Go :a[x]{:href="/docs/{{ data.slug }}"}') + expect(bindOf((inline.nodes[0] as any[])[3])).toBe(1) + const yaml = await parseWithBinding('::card\n---\n:title: Version {{ data.v }}\n---\n::') + expect(bindOf(yaml.nodes[0])).toBe(1) + const html = await parseWithBinding('See x') + expect((html.nodes[0] as any[])[3][1].$).toEqual({ html: 1, block: 0, bind: 1 }) + }) + + it('does not mark plain props, malformed templates or unknown roots', async () => { + for (const md of [ + 'Go :a[x]{title="Hi {{ data.x }}"}', + 'Go :a[x]{:title="Hi {{ data.x"}', + 'Go :a[x]{:title="Hi {{ world }}"}', + 'Go :a[x]{:title="data.x"}', + ]) { + const doc = await parseWithBinding(md) + const link = (doc.nodes[0] as any[])[3] + expect(link[0], md).toBe('a') + expect(bindOf(link), md).toBeUndefined() + } + }) + + it('does not mark anything without the plugin', async () => { + const doc = await parseMarkdown('Go :a[x]{:href="/docs/{{ data.slug }}"}') + expect((doc.nodes[0] as any[])[3][0]).toBe('a') + expect(bindOf((doc.nodes[0] as any[])[3])).toBeUndefined() + }) + + it('marks nested components', async () => { + const depth = 40 + const md = + Array.from({ length: depth }, (_, i) => `${':'.repeat(depth + 2 - i)}box`).join('\n') + + '\n:a[deep]{:href="/{{ data.x }}/y"}\n' + + Array.from({ length: depth }, (_, i) => ':'.repeat(i + 3)).join('\n') + const doc = await parseWithBinding(md) + let node: any = doc.nodes[0] + let levels = 0 + while (Array.isArray(node) && node[0] === 'box') { + levels++ + node = node.slice(2).find((child: unknown) => Array.isArray(child)) + } + expect(levels).toBe(depth) + const link = node[0] === 'a' ? node : node.slice(2).find((c: any) => Array.isArray(c) && c[0] === 'a') + expect(bindOf(link)).toBe(1) + }) + + it('renders the template only with the plugin', async () => { + const md = ':a[Read]{:href="/docs/{{ data.slug }}/overview"}' + const data = { slug: 'intro' } + expect(await renderHtmlForTest(await parseWithBinding(md), { data })).toContain('href="/docs/intro/overview"') + expect(await renderHtmlForTest(await parseMarkdown(md), { data })).not.toContain('/docs/intro/overview') + }) + + it('round-trips the template source through renderMarkdown, without the marker', async () => { + const md = '::card{:title="Version {{ data.v }}" :link="/docs/{{ data.slug }}"}\n::' + const doc = await parseWithBinding(md) + expect((await renderMarkdown(doc)).trim()).toBe(md) + const link = await parseWithBinding('[Read](/x){:title="Hi {{ data.name }}"}') + expect((await renderMarkdown(link)).trim()).toBe('[Read](/x){:title="Hi {{ data.name }}"}') + }) +}) diff --git a/packages/comark/test/props-validation.test.ts b/packages/comark/test/props-validation.test.ts index c33c140e..caecba78 100644 --- a/packages/comark/test/props-validation.test.ts +++ b/packages/comark/test/props-validation.test.ts @@ -541,6 +541,55 @@ describe('validateProp', () => { }) }) }) +describe('validateProp โ€” `{{ }}` URL templates', () => { + const allowlist = { allowedLinkPrefixes: ['https://myapp.com'] } + + it.each([ + '/docs/{{ data.slug }}', + './{{ data.slug }}', + '../{{ data.slug }}', + '?q={{ data.q }}', + '#{{ data.id }}', + 'docs/{{ data.slug }}', + 'https://myapp.com/{{ data.slug }}', + 'https://myapp.com/docs?q={{ data.q }}', + '{{ data.url }}', + '{{ data.base }}/{{ data.slug }}', + ])('keeps %s, whose literal prefix fixes the scheme and host', (value) => { + expect(validateProp(':href', value, allowlist)).toBe(value) + }) + + it.each([ + ['javascri{{ frontmatter.foo }}', 'a scheme the data can finish'], + ['java{{ data.x }}', 'a relative prefix the data can turn into a scheme'], + ['/{{ data.host }}', 'a lone slash the data can turn into //host'], + ['https://myapp.com{{ data.host }}', 'a host the data can extend'], + ['//myapp.com{{ data.host }}', 'a scheme-relative host the data can extend'], + ['https://evil.com/{{ data.slug }}', 'a host outside the allowlist'], + ['javascript:{{ data.code }}', 'an unsafe scheme'], + ])('rejects %s (%s)', (value) => { + expect(validateProp(':href', value, allowlist)).toBe(REJECTED_PROP) + }) + + it('applies to every URL prop, but not to text props', () => { + expect(validateProp(':to', 'javascri{{ frontmatter.foo }}')).toBe(REJECTED_PROP) + expect(validateProp(':link', '/{{ data.host }}')).toBe(REJECTED_PROP) + expect(validateProp(':title', 'java{{ data.x }}')).toBe('java{{ data.x }}') + }) + + it('applies allowedProtocols to the literal scheme', () => { + expect(validateProp(':href', 'mailto:{{ data.email }}', { allowedProtocols: ['https'] })).toBe(REJECTED_PROP) + expect(validateProp(':href', 'mailto:{{ data.email }}', { allowedProtocols: ['mailto'] })).toBe( + 'mailto:{{ data.email }}' + ) + }) + + it('leaves a malformed template and a JSON string to the usual checks', () => { + expect(validateProp(':href', '/{{ data.host')).toBe('/{{ data.host') + expect(validateProp(':href', '"/{{ data.host }}"')).toBe('"/{{ data.host }}"') + }) +}) + // โ”€โ”€โ”€ validateProps โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ describe('validateProps', () => { diff --git a/packages/comark/test/resolve-attributes.test.ts b/packages/comark/test/resolve-attributes.test.ts index eb482e19..0fba35d8 100644 --- a/packages/comark/test/resolve-attributes.test.ts +++ b/packages/comark/test/resolve-attributes.test.ts @@ -288,10 +288,21 @@ describe('resolveAttribute (single-attr lookup)', () => { }) }) +/** Attributes as the `binding()` plugin marks them, so their templates are interpolated. */ +const bound = (attrs: Record): Record => ({ ...attrs, $: { bind: 1 } }) + describe('resolveAttributes (interpolation)', () => { const renderData = makeRenderData({ - frontmatter: { slug: 'getting-started', n: 7 }, - data: { x: 'X', secret: 'S', a: 'java', b: 'script:alert(1)', bad: 'javascript:alert(1)', flag: true }, + frontmatter: { slug: 'getting-started', n: 7, foo: "pt: alert('xss')" }, + data: { + x: 'X', + secret: 'S', + a: 'java', + b: 'script:alert(1)', + bad: 'javascript:alert(1)', + flag: true, + host: '/evil.com', + }, props: { title: 'Ada' }, }) const modes = [ @@ -301,40 +312,44 @@ describe('resolveAttributes (interpolation)', () => { describe.each(modes)('%s mode', (_name, options) => { it('resolves a mixed template and drops the : prefix', () => { - const result = resolveAttributes({ ':href': '/docs/{{ frontmatter.slug }}/overview' }, renderData, options) + const result = resolveAttributes(bound({ ':href': '/docs/{{ frontmatter.slug }}/overview' }), renderData, options) expect(result).toEqual({ href: '/docs/getting-started/overview' }) }) it('inserts numbers and booleans as text', () => { - const result = resolveAttributes({ ':title': 'Item {{ frontmatter.n }} {{ data.flag }}' }, renderData, options) + const result = resolveAttributes( + bound({ ':title': 'Item {{ frontmatter.n }} {{ data.flag }}' }), + renderData, + options + ) expect(result).toEqual({ title: 'Item 7 true' }) }) it('uses the default when the path is missing', () => { - const result = resolveAttributes({ ':title': 'Hi {{ data.nope || friend }}' }, renderData, options) + const result = resolveAttributes(bound({ ':title': 'Hi {{ data.nope || friend }}' }), renderData, options) expect(result).toEqual({ title: 'Hi friend' }) }) it('omits the prop when a sole placeholder has nothing available', () => { - const result = resolveAttributes({ ':title': '{{ data.nope }}', id: 'keep' }, renderData, options) + const result = resolveAttributes(bound({ ':title': '{{ data.nope }}', id: 'keep' }), renderData, options) expect(result).toEqual({ id: 'keep' }) expect(result).not.toHaveProperty('title') expect(result).not.toHaveProperty(':title') }) it('resolves the props scope', () => { - const result = resolveAttributes({ ':label': 'Hi {{ props.title }}' }, renderData, options) + const result = resolveAttributes(bound({ ':label': 'Hi {{ props.title }}' }), renderData, options) expect(result).toEqual({ label: 'Hi Ada' }) }) it('leaves a non-: prop with a placeholder literal', () => { - const result = resolveAttributes({ title: 'Hi {{ data.x }}' }, renderData, options) + const result = resolveAttributes(bound({ title: 'Hi {{ data.x }}' }), renderData, options) expect(result).toEqual({ title: 'Hi {{ data.x }}' }) }) it('omits :href, :to and :src when the result has an unsafe scheme', () => { const result = resolveAttributes( - { ':href': '{{ data.bad }}', ':to': '{{ data.bad }}', ':src': '{{ data.bad }}', id: 'keep' }, + bound({ ':href': '{{ data.bad }}', ':to': '{{ data.bad }}', ':src': '{{ data.bad }}', id: 'keep' }), renderData, options ) @@ -342,63 +357,94 @@ describe('resolveAttributes (interpolation)', () => { }) it('omits a URL whose scheme is split across placeholders', () => { - const result = resolveAttributes({ ':href': '{{ data.a }}{{ data.b }}' }, renderData, options) + const result = resolveAttributes(bound({ ':href': '{{ data.a }}{{ data.b }}' }), renderData, options) expect(result).toEqual({}) }) + it('omits a URL whose scheme is split between literal text and data', () => { + const result = resolveAttributes( + bound({ ':to': 'javascri{{ frontmatter.foo }}', ':link': 'java{{ data.b }}', id: 'keep' }), + renderData, + options + ) + expect(result).toEqual({ id: 'keep' }) + }) + + it('omits a URL where literal text and data join into another host', () => { + expect(resolveAttributes(bound({ ':href': '/{{ data.host }}' }), renderData, options)).toEqual({}) + expect(resolveAttributes(bound({ ':href': '/docs/{{ data.host }}' }), renderData, options)).toEqual({ + href: '/docs//evil.com', + }) + }) + it('keeps a non-URL prop whose text looks like a scheme', () => { - const result = resolveAttributes({ ':title': 'JavaScript: {{ data.x }}' }, renderData, options) + const result = resolveAttributes(bound({ ':title': 'JavaScript: {{ data.x }}' }), renderData, options) expect(result).toEqual({ title: 'JavaScript: X' }) }) it('omits event handler props', () => { - const result = resolveAttributes({ ':onclick': '{{ data.x }}', id: 'keep' }, renderData, options) + const result = resolveAttributes(bound({ ':onclick': '{{ data.x }}', id: 'keep' }), renderData, options) expect(result).toEqual({ id: 'keep' }) }) it('leaves a nested object value untouched', () => { - const result = resolveAttributes({ ':config': { k: '{{ data.x }}' } }, renderData, options) + const result = resolveAttributes(bound({ ':config': { k: '{{ data.x }}' } }), renderData, options) expect(Object.values(result)).toEqual([{ k: '{{ data.x }}' }]) }) it('is stable when resolved twice and never expands inserted text', () => { const data = makeRenderData({ data: { x: '{{ data.secret }}', secret: 'S' } }) - const once = resolveAttributes({ ':title': 'A {{ data.x }}' }, data, options) + const once = resolveAttributes(bound({ ':title': 'A {{ data.x }}' }), data, options) expect(once).toEqual({ title: 'A {{ data.secret }}' }) - expect(resolveAttributes(once, data, options)).toEqual(once) + expect(resolveAttributes(bound(once), data, options)).toEqual(once) }) }) + it('does not interpolate without the binding() marker (default mode)', () => { + const result = resolveAttributes({ ':title': 'Item {{ frontmatter.n }}' }, renderData) + expect(result).toEqual({ ':title': 'Item {{ frontmatter.n }}' }) + }) + + it('does not interpolate without the binding() marker (parseJson mode)', () => { + const result = resolveAttributes({ ':title': 'Item {{ frontmatter.n }}' }, renderData, { parseJson: true }) + expect(result).toHaveProperty('title', undefined) + }) + it('keeps today behavior for a malformed template (default mode)', () => { - const result = resolveAttributes({ ':title': 'Hi {{ data.x' }, renderData) + const result = resolveAttributes(bound({ ':title': 'Hi {{ data.x' }), renderData) expect(result).toEqual({ ':title': 'Hi {{ data.x' }) }) it('keeps today behavior for a malformed template (parseJson mode)', () => { - const result = resolveAttributes({ ':title': 'Hi {{ data.x' }, renderData, { parseJson: true }) + const result = resolveAttributes(bound({ ':title': 'Hi {{ data.x' }), renderData, { parseJson: true }) expect(result).toHaveProperty('title', undefined) }) it('keeps today behavior for an unknown root (default mode)', () => { - const result = resolveAttributes({ ':title': 'Hi {{ item.name }}' }, renderData) + const result = resolveAttributes(bound({ ':title': 'Hi {{ item.name }}' }), renderData) expect(result).toEqual({ ':title': 'Hi {{ item.name }}' }) }) it('keeps today behavior for an unknown root (parseJson mode)', () => { - const result = resolveAttributes({ ':title': 'Hi {{ item.name }}' }, renderData, { parseJson: true }) + const result = resolveAttributes(bound({ ':title': 'Hi {{ item.name }}' }), renderData, { parseJson: true }) expect(result).toHaveProperty('title', undefined) }) it('keeps today behavior for a JSON string literal', () => { const value = '"{{ data.x }}"' - expect(resolveAttributes({ ':title': value }, renderData)).toEqual({ ':title': value }) - expect(resolveAttributes({ ':title': value }, renderData, { parseJson: true })).toEqual({ title: '{{ data.x }}' }) + expect(resolveAttributes(bound({ ':title': value }), renderData)).toEqual({ ':title': value }) + expect(resolveAttributes(bound({ ':title': value }), renderData, { parseJson: true })).toEqual({ + title: '{{ data.x }}', + }) }) it.each([':style', ':key', ':is', ':as'])('keeps today behavior for reserved prop %s', (key) => { const value = 'a {{ data.x }}' - expect(resolveAttributes({ [key]: value }, renderData)).toEqual({ [key]: value }) - expect(resolveAttributes({ [key]: value }, renderData, { parseJson: true })).toHaveProperty(key.slice(1), undefined) + expect(resolveAttributes(bound({ [key]: value }), renderData)).toEqual({ [key]: value }) + expect(resolveAttributes(bound({ [key]: value }), renderData, { parseJson: true })).toHaveProperty( + key.slice(1), + undefined + ) }) }) @@ -406,19 +452,28 @@ describe('resolveAttribute (interpolation)', () => { const renderData = makeRenderData({ frontmatter: { slug: 'docs' }, data: { bad: 'javascript:alert(1)' } }) it('returns the interpolated string', () => { - expect(resolveAttribute({ ':href': '/{{ frontmatter.slug }}/x' }, renderData, 'href')).toBe('/docs/x') + expect(resolveAttribute(bound({ ':href': '/{{ frontmatter.slug }}/x' }), renderData, 'href')).toBe('/docs/x') }) - it('returns the literal attribute when the template is rejected', () => { - expect(resolveAttribute({ ':href': '{{ data.bad }}' }, renderData, 'href')).toBeUndefined() - expect(resolveAttribute({ ':href': '{{ data.bad }}', href: '/safe' }, renderData, 'href')).toBe('/safe') + it('returns the literal attribute when the template is rejected, as resolveAttributes keeps it', () => { + expect(resolveAttribute(bound({ ':href': '{{ data.bad }}' }), renderData, 'href')).toBeUndefined() + expect(resolveAttribute(bound({ ':href': '{{ data.bad }}', href: '/safe' }), renderData, 'href')).toBe('/safe') + expect(resolveAttributes(bound({ ':href': '{{ data.bad }}', href: '/safe' }), renderData)).toEqual({ + href: '/safe', + }) }) it('returns the literal attribute when a sole placeholder is unresolved', () => { - expect(resolveAttribute({ ':href': '{{ data.nope }}' }, renderData, 'href')).toBeUndefined() + expect(resolveAttribute(bound({ ':href': '{{ data.nope }}' }), renderData, 'href')).toBeUndefined() }) it('keeps today behavior for a malformed template', () => { - expect(resolveAttribute({ ':href': '/x/{{ data.y' }, renderData, 'href')).toBe('/x/{{ data.y') + expect(resolveAttribute(bound({ ':href': '/x/{{ data.y' }), renderData, 'href')).toBe('/x/{{ data.y') + }) + + it('does not interpolate without the binding() marker', () => { + expect(resolveAttribute({ ':href': '/{{ frontmatter.slug }}/x' }, renderData, 'href')).toBe( + '/{{ frontmatter.slug }}/x' + ) }) }) From 789ccafbbe6d141ada30d1a68849672426bfce2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Mon, 19 Oct 2026 15:50:43 +0200 Subject: [PATCH 3/4] docs(playground): drop the dynamic props example --- docs/app/constants/index.ts | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/docs/app/constants/index.ts b/docs/app/constants/index.ts index ef45c227..736a74bc 100644 --- a/docs/app/constants/index.ts +++ b/docs/app/constants/index.ts @@ -435,28 +435,6 @@ Free for up to 5 services. No credit card. Deploys in under a minute. :: ` -export const dynamicPropsMarkdown = `--- -title: Dynamic props -description: Build prop values from fixed text and frontmatter -page: - "#comment": The "page" field is custom field and exists only for demonstration, it is no part of Comark core - maxWidth: 1120px -repo: comarkdown/comark -version: 0.7.0 -author: Ada ---- - -# {{ frontmatter.title }} - -Props that start with a colon can mix fixed text with placeholders. Edit the frontmatter and watch the preview. - -:::Button{:to="https://github.com/{{ frontmatter.repo }}" :label="Open {{ frontmatter.repo }} on GitHub" icon="i-lucide-github" target="_blank"} -::: - -::Alert{:title="Version {{ frontmatter.version }}" :description="Maintained by {{ frontmatter.author || the team }}" icon="i-lucide-info" color="secondary"} -:: -` - export type AiMode = 'showcase' | 'nuxt-ui' export type PlaygroundExample = { @@ -496,11 +474,6 @@ export const playgroundExamples: PlaygroundExample[] = [ mode: 'nuxt-ui', prompt: 'Landing page for a mountain bike rental in the Alps.', }, - { - label: 'Dynamic props', - value: 'dynamic-props', - content: dynamicPropsMarkdown, - }, ] export const defaultMarkdown = bookingMarkdown From 86c48ef60a8bc79527be80bf86489eb3bf7d2082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Mon, 19 Oct 2026 15:55:32 +0200 Subject: [PATCH 4/4] fix(security): require a complete host in URL template prefixes Browsers skip any run of `/` or `\` after a special scheme and after a leading `//`, so `https:/{{ x }}`, `https:///{{ x }}` and `///{{ x }}` let data choose the host. A URL template prefix must now hold a non-empty host that ends before the first placeholder, both in the `security()` check and in the render-time guard. Reported by CodeRabbit on #490. --- .../comark/src/internal/props-validation.ts | 26 ++++++++++++++----- .../src/internal/stringify/interpolate.ts | 13 +++------- packages/comark/test/props-validation.test.ts | 12 +++++++++ .../comark/test/resolve-attributes.test.ts | 7 +++++ 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/comark/src/internal/props-validation.ts b/packages/comark/src/internal/props-validation.ts index 07e544eb..73defaaf 100644 --- a/packages/comark/src/internal/props-validation.ts +++ b/packages/comark/src/internal/props-validation.ts @@ -296,13 +296,27 @@ export function validateProp(attribute: string, value: unknown, options: PropsVa const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i const DOUBLE_SLASH = /^[/\\]{2}/ const PATH_START = /[/\\?#]/ +/** Schemes whose URLs always have a host. Browsers skip any run of `/` or `\` after them. */ +const SPECIAL_SCHEMES = new Set(['http:', 'https:', 'ws:', 'wss:', 'ftp:', 'file:']) +// oxlint-disable-next-line no-control-regex +const LEADING_SLASHES = /^[\u0000-\u0020]*[/\\]*/ + +/** + * Whether the text after a scheme, or a scheme-relative `//`, already holds a + * complete host: the browser skips the leading slashes, then reads the host + * up to the next `/`, `\`, `?` or `#`. `//cdn.example.com/` does, while `/`, + * `///` and `//cdn.example.com` leave the host open to the data that follows. + */ +export function fixesHost(rest: string): boolean { + return rest.replace(LEADING_SLASHES, '').search(PATH_START) > 0 +} /** * Validate a URL template by its literal prefix, the text before the first placeholder. * Data can only fill what follows, so the prefix must fix the scheme and host on its own: - * `/docs/{{ x }}` and `https://myapp.com/{{ x }}` pass, while `java{{ x }}`, `/{{ x }}` and - * `https://myapp.com{{ x }}` are rejected. A template that starts with a placeholder is - * as trusted as a plain `:href="data.url"` binding. + * `/docs/{{ x }}` and `https://myapp.com/{{ x }}` pass, while `java{{ x }}`, `/{{ x }}`, + * `https:/{{ x }}` and `https://myapp.com{{ x }}` are rejected. A template that starts + * with a placeholder is as trusted as a plain `:href="data.url"` binding. */ function validateUrlTemplate(name: string, prefix: string, options: PropsValidationOptions): unknown { const literal = prefix.trimStart() @@ -310,9 +324,9 @@ function validateUrlTemplate(name: string, prefix: string, options: PropsValidat const scheme = URL_SCHEME.exec(literal) const rest = scheme ? literal.slice(scheme[0].length) : literal - if (DOUBLE_SLASH.test(rest)) { - // `https://hostโ€ฆ` or `//hostโ€ฆ`: the host must end before the data - if (!PATH_START.test(rest.slice(2))) return REJECTED_PROP + if ((scheme && SPECIAL_SCHEMES.has(scheme[0].toLowerCase())) || DOUBLE_SLASH.test(rest)) { + // `https:โ€ฆ` or `//โ€ฆ`: the host must be complete before the data + if (!fixesHost(rest)) return REJECTED_PROP } else if (!scheme) { // A relative prefix must already be a path, query or fragment, and `/` alone would let data add a host if (!PATH_START.test(literal) || literal === '/' || literal === '\\') return REJECTED_PROP diff --git a/packages/comark/src/internal/stringify/interpolate.ts b/packages/comark/src/internal/stringify/interpolate.ts index 8630984d..f19a1c60 100644 --- a/packages/comark/src/internal/stringify/interpolate.ts +++ b/packages/comark/src/internal/stringify/interpolate.ts @@ -1,5 +1,5 @@ import { get } from '../../utils/index.ts' -import { REJECTED_PROP, isUrlProp, normalizePropName, validateProp } from '../props-validation.ts' +import { REJECTED_PROP, fixesHost, isUrlProp, normalizePropName, validateProp } from '../props-validation.ts' import { MAX_TEMPLATE_LENGTH, parseTemplate } from '../template.ts' import type { NodeRenderData } from '../../types.ts' @@ -69,15 +69,10 @@ export function interpolateProp( if (output.length > MAX_TEMPLATE_LENGTH) return UNRESOLVED } - // Literal text must not join with data into a link to another host: `/{{ x }}` with `x = "/evil.com"`. - // A value that starts with data is as trusted as a plain `:href="data.url"` binding. + // Literal text must not join with data into a link to another host: `/{{ x }}` or `///{{ x }}` + // with `x = "evil.com"`. A value that starts with data is as trusted as a plain `:href="data.url"` binding. const prefix = parts[0] as string - if ( - prefix !== '' && - !SCHEME_RELATIVE.test(prefix) && - SCHEME_RELATIVE.test(output) && - isUrlProp(normalizePropName(name)) - ) { + if (prefix !== '' && SCHEME_RELATIVE.test(output) && !fixesHost(prefix) && isUrlProp(normalizePropName(name))) { return REJECTED_PROP } diff --git a/packages/comark/test/props-validation.test.ts b/packages/comark/test/props-validation.test.ts index caecba78..f3bdceff 100644 --- a/packages/comark/test/props-validation.test.ts +++ b/packages/comark/test/props-validation.test.ts @@ -567,10 +567,22 @@ describe('validateProp โ€” `{{ }}` URL templates', () => { ['//myapp.com{{ data.host }}', 'a scheme-relative host the data can extend'], ['https://evil.com/{{ data.slug }}', 'a host outside the allowlist'], ['javascript:{{ data.code }}', 'an unsafe scheme'], + ['https:/{{ data.path }}', 'a single slash browsers read as //host'], + ['https:{{ data.path }}', 'a scheme without a host'], + ['https:///{{ data.host }}', 'extra slashes before an empty host'], + ['HTTPS:\\\\{{ data.host }}', 'backslashes browsers read as //'], + ['///{{ data.host }}', 'a scheme-relative URL with an empty host'], ])('rejects %s (%s)', (value) => { expect(validateProp(':href', value, allowlist)).toBe(REJECTED_PROP) }) + it('keeps a scheme-relative prefix with a complete, allowed host', () => { + expect(validateProp(':src', '//cdn.example.com/{{ data.file }}')).toBe('//cdn.example.com/{{ data.file }}') + expect(validateProp(':href', 'https:/myapp.com/{{ data.slug }}', allowlist)).toBe( + 'https:/myapp.com/{{ data.slug }}' + ) + }) + it('applies to every URL prop, but not to text props', () => { expect(validateProp(':to', 'javascri{{ frontmatter.foo }}')).toBe(REJECTED_PROP) expect(validateProp(':link', '/{{ data.host }}')).toBe(REJECTED_PROP) diff --git a/packages/comark/test/resolve-attributes.test.ts b/packages/comark/test/resolve-attributes.test.ts index 0fba35d8..b87162a2 100644 --- a/packages/comark/test/resolve-attributes.test.ts +++ b/packages/comark/test/resolve-attributes.test.ts @@ -372,6 +372,13 @@ describe('resolveAttributes (interpolation)', () => { it('omits a URL where literal text and data join into another host', () => { expect(resolveAttributes(bound({ ':href': '/{{ data.host }}' }), renderData, options)).toEqual({}) + expect(resolveAttributes(bound({ ':href': '///{{ frontmatter.slug }}' }), renderData, options)).toEqual({}) + expect(resolveAttributes(bound({ ':href': ' /{{ data.host }}' }), renderData, options)).toEqual({}) + expect( + resolveAttributes(bound({ ':src': '//cdn.example.com/{{ frontmatter.slug }}' }), renderData, options) + ).toEqual({ + src: '//cdn.example.com/getting-started', + }) expect(resolveAttributes(bound({ ':href': '/docs/{{ data.host }}' }), renderData, options)).toEqual({ href: '/docs//evil.com', })