diff --git a/docs/content/4.plugins/1.built-in/security.md b/docs/content/4.plugins/1.built-in/security.md index e6a066727..33c9cb9f8 100644 --- a/docs/content/4.plugins/1.built-in/security.md +++ b/docs/content/4.plugins/1.built-in/security.md @@ -98,17 +98,29 @@ Attributes that can be abused regardless of value are always stripped: | `innerHTML` | Injects raw HTML through framework renderers | | `dangerouslySetInnerHTML` | Injects raw HTML through framework renderers | | `textContent` | Overwrites an element's children | +| `*Html` / `*-html` (`labelHtml`, `description-html`, `v-html`, …) | Component props rendered as raw HTML (for example with `v-html`) | +| `attributeName` targeting `href` | SVG `` / `` can rewrite a link to a script URL | + +Prop names are normalized before these checks: case, binding prefixes (`:`, `v-bind:`), Vue's `.prop` / `^attr` prefixes (`.innerHTML`, `^onclick`), and `:` / `-` / `_` separators are ignored. ::note Framework renderers (Vue, React, Svelte, Angular) never forward `innerHTML`, `dangerouslySetInnerHTML`, or `textContent` from document attributes, even without this plugin. Raw HTML has its own explicit path through the default `html` plugin. :: +### Nested props + +Object and array prop values (`:links='[{"to": "/docs", "label": "Docs"}]'`, YAML props) are validated recursively: every nested key goes through the same checks as a top-level prop, so nested `on*` handlers, `*Html` keys, and unsafe URLs are removed at any depth while the rest of the value is kept. Array items are checked against the prop name that holds them. + ### Protocol blocking -`href` and `src` values are decoded (URL-encoded and HTML entity variants included) and checked against a hard-coded block list. These protocols are **always** blocked, even if `allowedProtocols: ['*']` is set: +URL props are decoded (URL-encoded and HTML entity variants included) and checked against a hard-coded block list. A prop is a URL prop when its name is `href`, `src`, `to`, `action`, `poster`, `cite`, `background` or `ping`, or ends in `href`, `src`, `url`, `uri` or `link` (optionally plural), such as `xlink:href`, `imageUrl`, or `avatar-src`. Other props are never parsed as URLs, so prose such as `title="JavaScript: the good parts"` is kept. + +These protocols are **always** blocked, even if `allowedProtocols: ['*']` is set: `javascript:` · `vbscript:` · `data:text/html` · `data:text/javascript` · `data:text/vbscript` · `data:text/css` · `data:text/plain` · `data:text/xml` +A `data:` URL is checked the way browsers read its MIME type, which ignores spaces after `data:`. So `data: text/html,…` is blocked like `data:text/html,…`. + The same check applies to `:href` and `:src` bindings twice: on the JSON-decoded value at parse time, and again on the resolved value at render time, so bindings cannot smuggle an unsafe URL through frontmatter or other data sources. ::code-group @@ -148,11 +160,11 @@ Returns a `ComarkPlugin` that sanitizes the parsed AST. | [`blockedTags`](#options-blockedtags) | `string[]` | `[]` | Tag names to remove entirely from the AST | | [`allowedTags`](#options-allowedtags) | `string[]` | `[]` | Tag names to allow exclusively in the AST | | [`tagFallback`](#options-tagfallback) | `function` | `undefined` | Defines how to handle unallowed tags in the AST | -| [`allowedProtocols`](#options-allowedprotocols) | `string[]` | `['*']` | Protocols permitted in `href` and `src` | -| [`allowedLinkPrefixes`](#options-allowedlinkprefixes) | `string[]` | `['*']` | URL prefixes permitted in `href` | -| [`allowedImagePrefixes`](#options-allowedimageprefixes) | `string[]` | `['*']` | URL prefixes permitted in `src` | +| [`allowedProtocols`](#options-allowedprotocols) | `string[]` | `['*']` | Protocols permitted in URL props | +| [`allowedLinkPrefixes`](#options-allowedlinkprefixes) | `string[]` | `['*']` | URL prefixes permitted in link props (`href`, `to`, …) | +| [`allowedImagePrefixes`](#options-allowedimageprefixes) | `string[]` | `['*']` | URL prefixes permitted in image props (`src`, `*src`, `poster`) | | [`defaultOrigin`](#options-defaultorigin) | `string` | `undefined` | Rewrite disallowed URLs to this origin instead of stripping | -| [`allowDataImages`](#options-allowdataimages) | `boolean` | `true` | Allow `data:image/*` URIs in `src` | +| [`allowDataImages`](#options-allowdataimages) | `boolean` | `true` | Allow `data:image/*` URIs in image props | ### `blockedTags` @@ -206,7 +218,7 @@ security({ ### `allowedProtocols` -Restricts which URL protocols are permitted in `href` and `src` attributes. Use `['*']` to allow all protocols not already on the hard-coded block list. +Restricts which URL protocols are permitted in [URL props](#protocol-blocking) (`href`, `src`, `to`, …). Use `['*']` to allow all protocols not already on the hard-coded block list. ```typescript security({ @@ -220,7 +232,7 @@ The hard-coded unsafe protocols (`javascript:`, `vbscript:`, `data:text/*`) are ### `allowedLinkPrefixes` -Restricts which URLs are allowed in `href` attributes. Relative URLs (starting with `/`, `#`, etc.) are always allowed regardless of this setting. +Restricts which URLs are allowed in link props: every [URL prop](#protocol-blocking) except image props, such as `href`, `xlink:href`, and `to`. Relative URLs (starting with `/`, `#`, etc.) are always allowed regardless of this setting. Prefixes compare by parsed origin plus a path-segment boundary, not by raw string matching: `https://myapp.com` allows `https://myapp.com/docs` but never a lookalike host such as `https://myapp.com.evil.com`. Scheme-relative URLs (`//evil.com/page`) resolve to an absolute URL and go through the same checks. @@ -234,7 +246,7 @@ security({ ### `allowedImagePrefixes` -Same as `allowedLinkPrefixes` but applies to `src` attributes only. The two options are checked independently; restricting one does not affect the other. +Same as `allowedLinkPrefixes` but applies to image props only: `src`, props ending in `src` (`avatarSrc`), and `poster`. The two options are checked independently; restricting one does not affect the other. ```typescript security({ @@ -256,7 +268,7 @@ security({ ### `allowDataImages` -Controls whether `data:image/*` URIs are allowed in `src` attributes. Set to `false` to block base64-encoded images, which can be used as tracking pixels or embedded payloads. +Controls whether `data:image/*` URIs are allowed in image props (`src`, `*src`, `poster`). Set to `false` to block base64-encoded images, which can be used as tracking pixels or embedded payloads. ```typescript security({ diff --git a/packages/comark/src/internal/props-validation.ts b/packages/comark/src/internal/props-validation.ts index e83ec5c5d..bbe64dcf1 100644 --- a/packages/comark/src/internal/props-validation.ts +++ b/packages/comark/src/internal/props-validation.ts @@ -25,6 +25,15 @@ export const unsafeLinkPrefix = [ 'data:text/xml', ] +/** + * Whether `url` uses a hard-floor unsafe scheme. + */ +function hasUnsafeScheme(url: URL): boolean { + let href = url.href.toLowerCase() + if (url.protocol === 'data:') href = `data:${href.slice(5).trimStart()}` + return unsafeLinkPrefix.some((prefix) => href.startsWith(prefix)) +} + export interface PropsValidationOptions { allowedLinkPrefixes?: string[] allowedImagePrefixes?: string[] @@ -135,7 +144,7 @@ function validateUrl( } // Block known-unsafe protocols — hard floor, not overrideable by options - if (unsafeLinkPrefix.some((prefix) => url.href.toLowerCase().startsWith(prefix))) { + if (hasUnsafeScheme(url)) { return REJECTED_PROP } @@ -212,45 +221,109 @@ export function isUnsafeUrlValue(value: string): boolean { } catch { return false } - return unsafeLinkPrefix.some((prefix) => url.href.toLowerCase().startsWith(prefix)) + return hasUnsafeScheme(url) } -export function validateProp(attribute: string, value: unknown, options: PropsValidationOptions = {}): unknown { - const isBinding = /^(:|v-bind:)/.test(attribute) - attribute = attribute +// Attributes browsers navigate to or load as a URL, beyond the `*href` / +// `*src` / `*url` / `*link` names matched by URL_PROP_SUFFIX. `to` is the +// Vue Router / NuxtLink destination, which ends up as an ``. +const URL_PROPS = new Set(['to', 'action', 'poster', 'cite', 'background', 'ping']) +const URL_PROP_SUFFIX = /(?:href|src|url|uri|link)s?$/ + +/** + * Whether a normalized prop name carries a URL. Matching by name (rather than + * checking every string value) keeps prose such as + * `title="JavaScript: the good parts"` intact: it parses as a `javascript:` + * URL, but is never navigated to. + */ +function isUrlProp(name: string): boolean { + return URL_PROPS.has(name) || URL_PROP_SUFFIX.test(name) +} + +/** + * Lowercase and strip everything that does not change which DOM property a + * renderer writes: binding prefixes (`:`, `v-bind:`), Vue's force-prop/attr + * prefixes (`.innerHTML`, `^onclick`), and `:` / `-` / `_` separators + * (`xlink:href`, `label-html`). `@x` / `v-on:x` become `onx`. + */ +function normalizePropName(attribute: string): string { + return attribute .toLowerCase() - .replace(/^(:|v-bind:)/, '') - .replace(/^(@|v-on:)/, 'on') - .replace(/:/g, '') - if (attribute.startsWith('on') || unsafeAttributes.includes(attribute)) { + .replace(/^(?:v-bind:|[:.^])+/, '') + .replace(/^(?:@|v-on:)/, 'on') + .replace(/[:_-]/g, '') +} + +export function validateProp(attribute: string, value: unknown, options: PropsValidationOptions = {}): unknown { + const name = normalizePropName(attribute) + // `*Html` / `*-html` props (`innerHTML`, Nuxt UI's `labelHtml`, `v-html`) + // are markup sinks: components render them with `v-html` or equivalents. + if (name.startsWith('on') || name.endsWith('html') || unsafeAttributes.includes(name)) { return REJECTED_PROP } - if (attribute === 'href' || attribute === 'xlinkhref' || attribute === 'src') { - // A non-string href/src can reach here as an array/object from the YAML - // block-props JSON round-trip. Reject it instead of passing it through - // unvalidated (#367). - if (typeof value !== 'string') return REJECTED_PROP - - // Renderers JSON-decode `:binding` values before use, so validate the - // decoded form — otherwise ':href' with '"javascript:..."' (a JSON-quoted - // string) fails URL parsing and slips through as a "relative" URL. - let effective = value - if (isBinding) { - try { - const parsed: unknown = JSON.parse(value) - if (typeof parsed === 'string') effective = parsed - } catch { - // Not JSON — a dot-path binding or literal, validated as-is - } + // Renderers JSON-decode `:binding` strings before use, so validate the + // decoded form — otherwise ':href' with '"javascript:..."' (a JSON-quoted + // string) fails URL parsing and slips through as a "relative" URL, and a + // JSON array/object would skip the nested checks. + if (/^(?::|v-bind:)/.test(attribute) && typeof value === 'string') { + let decoded: unknown = value + try { + decoded = JSON.parse(value) + } catch { + // Not JSON — a dot-path binding or literal, validated as-is } - - const mode = attribute === 'src' ? 'image' : 'link' - const result = validateUrl(effective, mode, options) - if (result === REJECTED_PROP) return REJECTED_PROP + if (typeof decoded === 'object' && decoded !== null) { + const result = validateValue(name, decoded, options) + return result === REJECTED_PROP ? REJECTED_PROP : JSON.stringify(result) + } + 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. - return isBinding ? value : result + return result === REJECTED_PROP ? REJECTED_PROP : value + } + + return validateValue(name, value, options) +} + +function validateValue(name: string, value: unknown, options: PropsValidationOptions): unknown { + if (typeof value === 'string') { + // SVG `` / `` targeting `href` can rewrite a link to the + // script URL in `values` / `from` / `to` / `by` once the animation runs. + if (name === 'attributename') { + return decodeHtmlEntities(value).toLowerCase().includes('href') ? REJECTED_PROP : value + } + if (!isUrlProp(name)) return value + return validateUrl(value, name.endsWith('src') || name === 'poster' ? 'image' : 'link', options) + } + + // A non-string href/src can reach here as an array/object from the YAML + // block-props JSON round-trip, and is stringified when set as an attribute. + // Reject it instead of passing it through unvalidated (#367). Other URL + // props (`to`, `link`, …) accept route/link objects, validated below. + if (name === 'href' || name === 'xlinkhref' || name === 'src') return REJECTED_PROP + + // Array items inherit the prop name: `:hrefs='["javascript:…"]'`. + if (Array.isArray(value)) { + const result: unknown[] = [] + for (const item of value) { + const validated = validateValue(name, item, options) + if (validated !== REJECTED_PROP) result.push(validated) + } + return result + } + + // Object keys are validated like top-level props, at any depth: + // `:links='[{"to":"javascript:…","labelHtml":""}]'`. + if (typeof value === 'object' && value !== null) { + const entries: Array<[string, unknown]> = [] + for (const [key, item] of Object.entries(value)) { + const validated = validateProp(key, item, options) + if (validated !== REJECTED_PROP) entries.push([key, validated]) + } + // fromEntries defines a JSON `__proto__` key as an own property instead + // of swapping the result's prototype + return Object.fromEntries(entries) } return value @@ -280,6 +353,11 @@ export function validateProps( return [] } + // Parser metadata (`$.html`, `$.line`), not a prop + if (name === '$') { + return [[name, value]] + } + const result = validateProp(name, value, options) if (result === REJECTED_PROP) { diff --git a/packages/comark/src/internal/stringify/attributes.ts b/packages/comark/src/internal/stringify/attributes.ts index 9c7511b65..2ed7472f5 100644 --- a/packages/comark/src/internal/stringify/attributes.ts +++ b/packages/comark/src/internal/stringify/attributes.ts @@ -52,8 +52,10 @@ export function resolveAttributes( const value = attrs[key] const isBinding = key.charCodeAt(0) === 58 /* ':' */ const outKey = isBinding ? key.slice(1) : key + // Vue's `.prop` / `^attr` prefixes still write the named DOM property + const lowerOutKey = outKey.replace(/^[.^]/, '').toLowerCase() - if (HTML_SINK_PROPS.has(outKey.toLowerCase())) continue + if (HTML_SINK_PROPS.has(lowerOutKey)) continue let outValue: unknown let resultKey = key @@ -89,10 +91,9 @@ export function resolveAttributes( // (javascript:, data:text/html, …). Parse-time validation only sees the // literal path, so the resolved value is checked here — even when the // security plugin is not enabled. - const lowerOutKey = outKey.toLowerCase() if ( isBinding && - (lowerOutKey === 'href' || lowerOutKey === 'src' || lowerOutKey === 'xlink:href') && + (lowerOutKey === 'href' || lowerOutKey === 'src' || lowerOutKey === 'xlink:href' || lowerOutKey === 'to') && typeof outValue === 'string' && isUnsafeUrlValue(outValue) ) { diff --git a/packages/comark/test/plugins/security.test.ts b/packages/comark/test/plugins/security.test.ts index e28ed0f58..c6f6c2e96 100644 --- a/packages/comark/test/plugins/security.test.ts +++ b/packages/comark/test/plugins/security.test.ts @@ -199,6 +199,22 @@ click expect(anchor![1].href).toBeUndefined() }) + it('blocks a data:text/html URL with a space before its MIME type', async () => { + const tree = await parseWithSecurity( + `\ +click + +`.trim() + ) + + const anchor = collectElements(tree.nodes).find((element) => element[0] === 'a') + const iframe = collectElements(tree.nodes).find((element) => element[0] === 'iframe') + expect(anchor).toBeDefined() + expect(anchor![1].href).toBeUndefined() + expect(iframe).toBeDefined() + expect(iframe![1].src).toBeUndefined() + }) + it('blocks Vue directive-form event handlers and unsafe URLs', async () => { const tree = await parseWithSecurity( `\ @@ -338,6 +354,93 @@ home: javascript:alert(1) }) }) +describe('security plugin — component props beyond href/src', () => { + const untrusted = { + blockedTags: ['script', 'iframe', 'embed', 'form', 'base', 'meta', 'link', 'style'], + allowDataImages: false, + } + + it('strips a javascript: URL from a `to` prop', async () => { + const tree = await parseWithSecurity('::callout{to="javascript:alert(1)"}\nclick\n::', untrusted) + expect(tree.nodes).toEqual([['callout', {}, 'click']]) + }) + + it('sanitizes bound arrays recursively and drops *-html props', async () => { + const tree = await parseWithSecurity( + `::u-button{:links='[{"to":"javascript:alert(3)","label":"Docs","onClick":"alert(5)","descriptionHtml":""},{"to":"/docs","label":"Safe"}]' label-html="" label="Go"}\n::`, + untrusted + ) + expect(tree.nodes).toEqual([ + [ + 'u-button', + { + ':links': [{ label: 'Docs' }, { to: '/docs', label: 'Safe' }], + label: 'Go', + }, + ], + ]) + }) + + it('sanitizes JSON bindings that reach the tree as strings (raw HTML)', async () => { + const tree = await parseWithSecurity( + ``, + untrusted + ) + const [element] = collectElements(tree.nodes).filter((node) => node[0] === 'u-page-links') + expect(JSON.parse(element![1][':links'] as string)).toEqual([{ label: 'x' }]) + }) + + it('removes SVG animation targeting href', async () => { + const tree = await parseWithSecurity( + `click`, + untrusted + ) + const animations = collectElements(tree.nodes).filter((node) => node[0] === 'animate' || node[0] === 'set') + expect(animations).toHaveLength(2) + for (const [, props] of animations) { + expect(props).not.toHaveProperty('attributename') + expect(props).not.toHaveProperty('to') + } + }) + + it('strips Vue `.prop` / `^attr` prefixed props from YAML props', async () => { + const tree = await parseWithSecurity( + `::comp +--- +.innerHTML: +^onclick: alert(1) +^href: javascript:alert(2) +.title: kept +--- +::`, + untrusted + ) + expect(tree.nodes).toEqual([['comp', { '.title': 'kept' }]]) + }) + + it('keeps ordinary content and safe URLs', async () => { + const tree = await parseWithSecurity( + `::card{title="JavaScript: the good parts" description="vbscript: a history" to="https://nuxt.com/docs" :links='[{"label":"JavaScript: intro","to":"/docs#intro"},{"to":"mailto:hi@nuxt.com"}]'} +![logo](data:image/png;base64,abc) +:: + +`, + { allowDataImages: true } + ) + const [card] = collectElements(tree.nodes).filter((node) => node[0] === 'card') + expect(card![1]).toEqual({ + title: 'JavaScript: the good parts', + description: 'vbscript: a history', + to: 'https://nuxt.com/docs', + ':links': [{ label: 'JavaScript: intro', to: '/docs#intro' }, { to: 'mailto:hi@nuxt.com' }], + }) + const [img] = collectElements(tree.nodes).filter((node) => node[0] === 'img') + expect(img![1].src).toBe('data:image/png;base64,abc') + const [animate] = collectElements(tree.nodes).filter((node) => node[0] === 'animate') + expect(animate![1]).toMatchObject({ attributename: 'opacity', values: '0;1' }) + }) +}) + describe('security plugin — blockedTags', () => { it('removes a blocked tag', async () => { const tree = makeTree([ diff --git a/packages/comark/test/props-validation.test.ts b/packages/comark/test/props-validation.test.ts index 6c067de34..c33c140ed 100644 --- a/packages/comark/test/props-validation.test.ts +++ b/packages/comark/test/props-validation.test.ts @@ -93,6 +93,18 @@ describe('validateProp', () => { expect(validateProp('href', 'data:text/vbscript,evil')).toBe(REJECTED_PROP) }) + it('blocks data:text/html with spaces before the MIME type', () => { + // Browsers trim them and render HTML; `new URL()` keeps them in `href`. + expect(validateProp('href', 'data: text/html,')).toBe(REJECTED_PROP) + expect(validateProp('href', 'data: text/html,')).toBe(REJECTED_PROP) + expect(validateProp('src', 'data: text/html,')).toBe(REJECTED_PROP) + expect(validateProp('to', 'data: text/html,')).toBe(REJECTED_PROP) + }) + + it('keeps data:image URLs with spaces before the MIME type when data images are allowed', () => { + expect(validateProp('src', 'data: image/png;base64,abc')).toBe('data: image/png;base64,abc') + }) + it('blocks entity-encoded javascript: hrefs', () => { expect(validateProp('href', 'javascript:alert(1)')).toBe(REJECTED_PROP) expect(validateProp('href', 'javascript:alert(1)')).toBe(REJECTED_PROP) @@ -369,6 +381,140 @@ describe('validateProp', () => { }) }) + describe('URL props beyond href/src', () => { + it('blocks script URLs in `to`, `*Url`, `*Link`, `*Src` and other URL attributes', () => { + const payloads: Array<[string, string]> = [ + ['to', 'javascript:alert(1)'], + ['To', 'JaVaScRiPt:alert(1)'], + ['to', 'javascript:alert(1)'], + ['to', 'javascript%3Aalert(1)'], + [':to', '"javascript:alert(1)"'], + ['imageUrl', 'javascript:alert(1)'], + ['cta-link', 'vbscript:msgbox(1)'], + ['avatar_src', 'data:text/html,'], + ['action', 'javascript:alert(1)'], + ['poster', 'javascript:alert(1)'], + ] + for (const [attribute, value] of payloads) { + expect(validateProp(attribute, value), `${attribute}="${value}"`).toBe(REJECTED_PROP) + } + }) + + it('keeps safe URLs in URL props', () => { + for (const value of ['https://nuxt.com', 'mailto:hi@nuxt.com', '/docs', '#anchor', 'docs/page']) { + expect(validateProp('to', value)).toBe(value) + } + }) + + it('applies the image policy to *src props and the link policy to `to`', () => { + expect(validateProp('avatarSrc', 'data:image/png;base64,abc', { allowDataImages: false })).toBe(REJECTED_PROP) + expect(validateProp('avatarSrc', 'data:image/png;base64,abc')).toBe('data:image/png;base64,abc') + expect(validateProp('to', 'http://example.com', { allowedProtocols: ['https'] })).toBe(REJECTED_PROP) + }) + + it('does not treat prose in non-URL props as a URL', () => { + expect(validateProp('title', 'JavaScript: the good parts')).toBe('JavaScript: the good parts') + expect(validateProp('label', 'javascript:alert(1)')).toBe('javascript:alert(1)') + expect(validateProp('tooltip', 'data:text/html is blocked')).toBe('data:text/html is blocked') + }) + + it('keeps route objects in `to`', () => { + expect(validateProp(':to', { path: '/docs', query: { q: 'x' } })).toEqual({ path: '/docs', query: { q: 'x' } }) + }) + }) + + describe('SVG animation', () => { + it('blocks attributeName targeting href in any form', () => { + expect(validateProp('attributeName', 'href')).toBe(REJECTED_PROP) + expect(validateProp('attributename', 'xlink:href')).toBe(REJECTED_PROP) + expect(validateProp('attributeName', 'HREF')).toBe(REJECTED_PROP) + expect(validateProp('attributeName', 'href')).toBe(REJECTED_PROP) + expect(validateProp(':attributeName', '"href"')).toBe(REJECTED_PROP) + }) + + it('keeps animations of other attributes', () => { + expect(validateProp('attributeName', 'opacity')).toBe('opacity') + expect(validateProp('values', '0;1')).toBe('0;1') + }) + }) + + describe('markup props', () => { + it('blocks *Html / *-html props', () => { + for (const attribute of ['labelHtml', 'label-html', 'descriptionHtml', 'suffix_html', ':labelHtml', 'v-html']) { + expect(validateProp(attribute, ''), attribute).toBe(REJECTED_PROP) + } + }) + + it('keeps props that only contain "html" mid-name', () => { + expect(validateProp('htmlFor', 'email')).toBe('email') + }) + }) + + describe('Vue `.prop` and `^attr` prefixes', () => { + it('validates the prop name behind the prefix', () => { + const payloads: Array<[string, string]> = [ + ['.innerHTML', ''], + ['^innerHTML', ''], + ['.textContent', 'overlay'], + ['^onclick', 'alert(1)'], + ['.onclick', 'alert(1)'], + [':.innerHTML', '""'], + ['^href', 'javascript:alert(1)'], + ['.srcdoc', ''], + ] + for (const [attribute, value] of payloads) { + expect(validateProp(attribute, value), attribute).toBe(REJECTED_PROP) + } + }) + + it('keeps safe prefixed props', () => { + expect(validateProp('.title', 'hello')).toBe('hello') + expect(validateProp('^href', 'https://example.com')).toBe('https://example.com') + }) + }) + + describe('nested values', () => { + it('removes unsafe keys and URLs at any depth', () => { + const value = [ + { + label: 'Docs', + to: 'javascript:alert(1)', + onClick: 'alert(2)', + labelHtml: '', + '.innerHTML': 'x', + children: [{ label: 'Deep', href: 'javascript:alert(4)', avatar: { src: 'javascript:alert(5)', alt: 'a' } }], + }, + { label: 'Safe', to: '/docs' }, + ] + expect(validateProp(':links', value)).toEqual([ + { label: 'Docs', children: [{ label: 'Deep', avatar: { alt: 'a' } }] }, + { label: 'Safe', to: '/docs' }, + ]) + }) + + it('validates array items against the prop name', () => { + expect(validateProp(':hrefs', ['javascript:alert(1)', '/ok', ['javascript:alert(2)']])).toEqual(['/ok', []]) + expect(validateProp(':items', ['JavaScript: the good parts'])).toEqual(['JavaScript: the good parts']) + }) + + it('sanitizes JSON-encoded binding strings and re-encodes them', () => { + const result = validateProp(':links', '[{"to":"javascript:alert(1)","label":"x"},{"to":"/ok"}]') + expect(JSON.parse(result as string)).toEqual([{ label: 'x' }, { to: '/ok' }]) + }) + + it('passes nested options through', () => { + expect(validateProp(':item', { to: 'http://example.com', label: 'x' }, { allowedProtocols: ['https'] })).toEqual({ + label: 'x', + }) + }) + + it('keeps a JSON `__proto__` key as data instead of a prototype', () => { + const result = validateProp(':item', JSON.parse('{"__proto__":{"polluted":true},"label":"x"}')) as object + expect(Object.getPrototypeOf(result)).toBe(Object.prototype) + expect((result as Record).polluted).toBeUndefined() + }) + }) + describe('Vue directive form', () => { it('blocks Vue directive-form event handlers and unsafe URLs', () => { const payloads: Array<[string, string]> = [ @@ -451,6 +597,11 @@ describe('validateProps', () => { spy.mockRestore() }) + it('keeps parser metadata under `$` untouched', () => { + const result = validateProps('svg', { $: { html: 1, block: 0 }, onclick: 'x' }) + expect(result).toEqual({ $: { html: 1, block: 0 } }) + }) + it('passes options through to prop validation', () => { const result = validateProps('a', { href: 'http://example.com' }, { allowedProtocols: ['https'] }) expect(result).not.toHaveProperty('href') diff --git a/packages/comark/test/resolve-attributes.test.ts b/packages/comark/test/resolve-attributes.test.ts index bab8b0b25..e234dd123 100644 --- a/packages/comark/test/resolve-attributes.test.ts +++ b/packages/comark/test/resolve-attributes.test.ts @@ -128,6 +128,15 @@ describe('HTML sink props', () => { ) expect(result).toEqual({ id: 'keep' }) }) + + it('drops sink props behind Vue `.prop` / `^attr` prefixes', () => { + const result = resolveAttributes( + { '.innerHTML': '', '^textContent': 'overlay', ':.innerHTML': '""', '.title': 'keep' }, + makeRenderData(), + { parseJson: true } + ) + expect(result).toEqual({ '.title': 'keep' }) + }) }) describe('unsafe URL bindings (render-time hard floor)', () => { @@ -151,6 +160,22 @@ describe('unsafe URL bindings (render-time hard floor)', () => { expect(result).toEqual({}) }) + it('drops :src bindings resolving to `data: text/html` — browsers trim the space', () => { + const result = resolveAttributes( + { ':src': 'frontmatter.cover' }, + makeRenderData({ frontmatter: { cover: 'data: text/html,' } }) + ) + expect(result).toEqual({}) + }) + + it('drops `:^href` / `:.href` bindings resolving to javascript: — Vue still writes `href`', () => { + const result = resolveAttributes( + { ':^href': 'frontmatter.home', ':.href': 'frontmatter.home' }, + makeRenderData({ frontmatter: { home: 'javascript:alert(1)' } }) + ) + expect(result).toEqual({}) + }) + it('drops bindings whose resolved value is entity-encoded javascript:', () => { const result = resolveAttributes( { ':href': 'frontmatter.home' }, diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 521a71aac..bd7e87ff7 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -67,7 +67,7 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/react": "38.0k (76 files)", "@comark/svelte": "45.9k (84 files)", "@comark/vue": "56.2k (80 files)", - "comark": "376k (160 files)", + "comark": "377k (160 files)", } `) })