Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions docs/content/4.plugins/1.built-in/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<animate>` / `<set>` 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
Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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({
Expand All @@ -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.

Expand All @@ -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({
Expand All @@ -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({
Expand Down
140 changes: 109 additions & 31 deletions packages/comark/src/internal/props-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 `<a href>`.
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 `<animate>` / `<set>` 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":"<img onerror>"}]'`.
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
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 4 additions & 3 deletions packages/comark/src/internal/stringify/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
) {
Expand Down
Loading
Loading