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 1d9abd5a..34188a88 100644 --- a/docs/content/2.syntax/2.components.md +++ b/docs/content/2.syntax/2.components.md @@ -314,6 +314,44 @@ 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 + +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 and in HTML attributes too: + +```mdc +::card +--- +:title: Item {{ frontmatter.id }} +--- +:: + +Docs +``` + +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 + +- 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 `{{`. +- The `key`, `ref`, `is`, `as`, and `style` props, malformed placeholders, and JSON values are left as written. + ## 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..3ffd9433 100644 --- a/docs/content/4.plugins/1.built-in/binding.md +++ b/docs/content/4.plugins/1.built-in/binding.md @@ -282,6 +282,24 @@ 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. +## 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 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..0ba31877 100644 --- a/docs/content/4.plugins/1.built-in/security.md +++ b/docs/content/4.plugins/1.built-in/security.md @@ -137,6 +137,26 @@ The same check applies to `:href` and `:src` bindings twice: on the JSON-decoded :: +### Interpolated props + +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 }}"`. + +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`. + --- ## 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-ansi/test/index.test.ts b/packages/comark-ansi/test/index.test.ts index 0439a89d..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, @@ -404,6 +405,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 }}"}', { 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 }}"}', { 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 }}"}', { 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:') + 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..239a2f4b 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 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('') + }) + 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/string.test.ts b/packages/comark-html/test/string.test.ts index fe0ba0bb..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 () => { @@ -206,5 +207,36 @@ 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"}', { + 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::', { + 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 }}"}', { plugins: [binding()] }) + 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 }}"}', { plugins: [binding()] }) + const html = await renderHtmlFromDocument(doc, { data: { t: 'a"b = {}) { - const tree = await parseMarkdown(markdown) +async function renderMarkdown(markdown: string, { plugins, ...props }: Record = {}) { + const tree = await parseMarkdown(markdown, { plugins }) return renderToString( { + const html = await renderMarkdown( + `::badge{:label="Hi {{ data.name }}"} +:: + +:a[Docs]{:href="/d/{{ data.id }}"}`, + { components: { badge: Badge }, data: { name: 'Ada', id: 7 }, plugins: [binding()] } + ) + 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)' }, plugins: [binding()] }) + expect(bad).not.toContain('javascript:') + expect(bad).not.toContain('href') + expect(bad).toContain('x') + const ok = await renderMarkdown(md, { data: { url: '/ok' }, plugins: [binding()] }) + expect(ok).toContain('href="/ok"') + }) + it('leaves non-:prefix attributes untouched', async () => { const html = await renderMarkdown( `--- diff --git a/packages/comark-svelte/test/plugin-binding.test.ts b/packages/comark-svelte/test/plugin-binding.test.ts index e6e2b655..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', () => { @@ -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 }}"}', { 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 }}"}', { plugins: [binding()] }) + 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-vue/test/interpolation-reactivity.test.ts b/packages/comark-vue/test/interpolation-reactivity.test.ts new file mode 100644 index 00000000..2726b347 --- /dev/null +++ b/packages/comark-vue/test/interpolation-reactivity.test.ts @@ -0,0 +1,74 @@ +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 + 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, { plugins: [binding()] }) + 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/renderer-data-binding.test.ts b/packages/comark-vue/test/renderer-data-binding.test.ts index 4473d456..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', @@ -32,8 +33,15 @@ const Card = defineComponent({ }, }) -async function renderMarkdown(markdown: string, props: Record = {}) { - const tree = await parseMarkdown(markdown) +const VariantCard = defineComponent({ + props: { variant: { type: String, default: 'primary' } }, + setup(props) { + return () => h('div', { class: `card ${props.variant}` }) + }, +}) + +async function renderMarkdown(markdown: string, { plugins, ...props }: Record = {}) { + const tree = await parseMarkdown(markdown, { plugins }) const app = createSSRApp({ setup: () => () => h(MarkdownDocument, { value: tree, ...props }), }) @@ -112,6 +120,36 @@ 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 }, plugins: [binding()] } + ) + 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)' }, plugins: [binding()] }) + expect(bad).not.toContain('javascript:') + expect(bad).not.toContain('href') + expect(bad).toContain('x') + 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"') + }) + it('leaves non-:prefix attributes untouched', async () => { const html = await renderMarkdown( `--- 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 new file mode 100644 index 00000000..fe3bbde8 --- /dev/null +++ b/packages/comark/SPEC/COMARK/data-binding-interpolation.md @@ -0,0 +1,81 @@ +--- +options: + plugins: + - binding +--- + +## 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 }}", + "$": { + "bind": 1 + } + } + ], + [ + "card", + { + ":title": "Item {{ frontmatter.id }}", + ":to": "javascript:{{ frontmatter.slug }}", + "$": { + "bind": 1 + } + } + ] + ] +} +``` + +## 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/props-validation.ts b/packages/comark/src/internal/props-validation.ts index bbe64dcf..73defaaf 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,49 @@ 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 = /[/\\?#]/ +/** 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 }}`, + * `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() + if (literal === '') return prefix + + const scheme = URL_SCHEME.exec(literal) + const rest = scheme ? literal.slice(scheme[0].length) : literal + 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 + } + + // 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 2ed7472f..f8e9d0b3 100644 --- a/packages/comark/src/internal/stringify/attributes.ts +++ b/packages/comark/src/internal/stringify/attributes.ts @@ -2,7 +2,8 @@ import { stringifyYaml } from '../yaml.ts' import { escapeHtml, get } from '../../utils/index.ts' import { isUnsafeUrlValue } from '../props-validation.ts' import { pickFence } from './fence.ts' -import type { NodeRenderData } from '../../types.ts' +import { interpolateProp } from './interpolate.ts' +import type { ElementNodeAttributes, NodeRenderData } from '../../types.ts' export interface ResolveAttributesOptions { /** @@ -39,6 +40,10 @@ 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. + * + * 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, @@ -46,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 @@ -59,8 +65,14 @@ export function resolveAttributes( 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 { @@ -108,13 +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') { + 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 } @@ -193,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 new file mode 100644 index 00000000..f19a1c60 --- /dev/null +++ b/packages/comark/src/internal/stringify/interpolate.ts @@ -0,0 +1,81 @@ +import { get } from '../../utils/index.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' + +/** Returned when the prop must be omitted because no value is available. */ +export const UNRESOLVED = Symbol('comark:unresolved-template') + +/** Props that are not plain text, or that choose the rendered element. */ +const RESERVED_NAMES = new Set(['key', 'ref', 'is', 'as', 'style']) +/** 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‪-‮⁦-⁩]/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 { + 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`. + * 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. + */ +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 (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 { + 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_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_TEMPLATE_LENGTH) return UNRESOLVED + } + + // 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(output) && !fixesHost(prefix) && isUrlProp(normalizePropName(name))) { + return REJECTED_PROP + } + + // 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/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 542ae62e..854bd83f 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,74 @@ 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({ $: { bind: 1 }, ':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( + { $: { bind: 1 }, ':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({ $: { 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({ $: { 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) + }) + + it('keeps a closed placeholder followed by unclosed `{{` as written', () => { + const value = '{{ data.x }}{{ '.repeat(250) + const start = performance.now() + 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({ $: { 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) + }) + + it('leaves a template over the length limit as written', () => { + const value = '{{ data.x }}'.repeat(500) + const result = resolveAttributes({ $: { bind: 1 }, ':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/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..f3bdceff 100644 --- a/packages/comark/test/props-validation.test.ts +++ b/packages/comark/test/props-validation.test.ts @@ -541,6 +541,67 @@ 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'], + ['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) + 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 e234dd12..b87162a2 100644 --- a/packages/comark/test/resolve-attributes.test.ts +++ b/packages/comark/test/resolve-attributes.test.ts @@ -287,3 +287,200 @@ describe('resolveAttribute (single-attr lookup)', () => { expect(value).toBe(config) }) }) + +/** 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, 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 = [ + ['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(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( + 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(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(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(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(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( + bound({ ':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(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': '///{{ 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', + }) + }) + + it('keeps a non-URL prop whose text looks like a scheme', () => { + const result = resolveAttributes(bound({ ':title': 'JavaScript: {{ data.x }}' }), renderData, options) + expect(result).toEqual({ title: 'JavaScript: X' }) + }) + + it('omits event handler props', () => { + 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(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(bound({ ':title': 'A {{ data.x }}' }), data, options) + expect(once).toEqual({ title: 'A {{ data.secret }}' }) + 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(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(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(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(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(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(bound({ [key]: value }), renderData)).toEqual({ [key]: value }) + expect(resolveAttributes(bound({ [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(bound({ ':href': '/{{ frontmatter.slug }}/x' }), renderData, 'href')).toBe('/docs/x') + }) + + 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(bound({ ':href': '{{ data.nope }}' }), renderData, 'href')).toBeUndefined() + }) + + it('keeps today behavior for a malformed template', () => { + 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' + ) + }) +})