Skip to content
Open
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ packages/comark/
│ ├── shiki.ts # Shared Shiki runtime used by both entry points
│ ├── frontmatter.ts # parseFrontmatter() / renderFrontmatter()
│ ├── yaml.ts # YAML helpers
│ ├── props-validation.ts # Component props validation
│ ├── sanitize.ts # Component props validation and frontmatter sanitization
│ ├── parse/ # Parsing pipeline
│ │ ├── token-processor.ts # markdown-exit tokens → document nodes
│ │ ├── auto-close/ # Self-healing for incomplete markdown
Expand Down Expand Up @@ -489,6 +489,7 @@ import {
isHtmlVoidElement,
HTML_VOID_ELEMENTS,
isMarkdownDocument,
sanitizeData,
} from 'comark/utils'
import { noopTracer, withSpan } from 'comark/utils/trace'

Expand Down
43 changes: 38 additions & 5 deletions docs/content/4.plugins/1.built-in/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,11 @@ Object and array prop values (`:links='[{"to": "/docs", "label": "Docs"}]'`, YAM

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:
These URLs 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`
- `javascript:` and `vbscript:` URLs
- `data:` URLs in link props (`href`, `to`, …)
- `data:` URLs in image props, unless their MIME type is `image/*` (see [`allowDataImages`](#options-allowdataimages))

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,…`.

Expand All @@ -137,6 +139,24 @@ The same check applies to `:href` and `:src` bindings twice: on the JSON-decoded

::

### Frontmatter

Renderers spread frontmatter into components, so it is sanitized with the same rules as props, at any depth:

- event handler and markup keys are removed, with the same name checks
- URLs in URL keys (`to`, `link`, `imageSrc`, …) are held to the protocol floor and the URL options
- an `as` or `is` value naming a blocked, unsafe or not-allowed tag is removed

Dropped array items are removed without leaving a hole. Set [`frontmatter: false`](#options-frontmatter) to opt out.

To apply the same rules to objects that don't come through the parser, such as a YAML or JSON file you load yourself, use `sanitizeData()` from `comark/utils`. It cleans the data in place and returns the dotted paths it removed:

```typescript
import { sanitizeData } from 'comark/utils'

const removed = sanitizeData(data, { blockedTags: ['script'], allowDataImages: false })
```

---

## API
Expand Down Expand Up @@ -165,6 +185,7 @@ Returns a `ComarkPlugin` that sanitizes the parsed AST.
| [`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 image props |
| [`frontmatter`](#options-frontmatter) | `boolean` | `true` | Sanitize frontmatter as well as the AST |

### `blockedTags`

Expand Down Expand Up @@ -197,7 +218,9 @@ security({
})
```

The `as` prop (which makes framework renderers resolve a different component than the element's own tag) is held to the same filters: an `as` value naming a blocked or not-allowed tag is stripped, and the element falls back to its own tag.
The `as` and `is` props (which make framework renderers resolve a different component than the element's own tag) are held to the same filters: a value naming a blocked or not-allowed tag, or `object`, is stripped, and the element falls back to its own tag.

Their bindings are checked too. A JSON binding (`:as='"section"'`) is checked decoded, as renderers decode it. A dot-path binding (`:as="frontmatter.tag"`) only resolves at render time, after every check, so it is always stripped.

### `tagFallback`

Expand Down Expand Up @@ -227,7 +250,7 @@ security({
```

::warning
The hard-coded unsafe protocols (`javascript:`, `vbscript:`, `data:text/*`) are a floor that cannot be overridden. Even `allowedProtocols: ['javascript']` will not unblock `javascript:` URLs.
The hard-coded floor (`javascript:`, `vbscript:`, and `data:` outside images) cannot be overridden. Even `allowedProtocols: ['javascript']` will not unblock `javascript:` URLs.
::

### `allowedLinkPrefixes`
Expand Down Expand Up @@ -277,9 +300,19 @@ security({
```

::tip
`data:text/*` variants in `href` are always blocked by the hard-coded protocol list regardless of this setting.
`data:` URLs in link props, and non-image `data:` URLs anywhere, are always blocked by the hard-coded floor regardless of this setting.
::

### `frontmatter`

Controls whether frontmatter is sanitized along with the AST (see [Frontmatter](#frontmatter)). Set to `false` when your frontmatter is trusted and never spread into components.

```typescript
security({
frontmatter: false
})
```

---

## Examples
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,20 @@ export const unsafeTags = ['object']
// path via the `html` plugin and does not need these).
export const unsafeAttributes = ['srcdoc', 'formaction', 'innerhtml', 'dangerouslysetinnerhtml', 'textcontent']

export const unsafeLinkPrefix = [
'javascript:',
'data:text/html',
'vbscript:',
'data:text/javascript',
'data:text/vbscript',
'data:text/css',
'data:text/plain',
'data:text/xml',
]
export const unsafeProtocols = ['javascript:', 'vbscript:']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it worth too keep other urls in unsafe list, although that modern browsers have protection layer, but those urls still are malicious in older versions.


/**
* Whether `url` uses a hard-floor unsafe scheme.
* Whether `url` is a known-unsafe protocol.
*/
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))
function isUnsafeProtocol(url: URL, allowDataImage: boolean): boolean {
if (unsafeProtocols.includes(url.protocol)) return true
if (url.protocol !== 'data:') return false
if (!allowDataImage) return true

const body = url.href.slice('data:'.length)
let end = 0
while (end < body.length && body[end] !== ',' && body[end] !== ';') end++
return !body.slice(0, end).trim().toLowerCase().startsWith('image/')
}

export interface PropsValidationOptions {
Expand Down Expand Up @@ -143,13 +139,7 @@ function validateUrl(
url = resolved
}

// Block known-unsafe protocols — hard floor, not overrideable by options
if (hasUnsafeScheme(url)) {
return REJECTED_PROP
}

// Block data: images when allowDataImages is false
if (mode === 'image' && !allowDataImages && url.protocol === 'data:') {
if (isUnsafeProtocol(url, mode === 'image' && allowDataImages)) {
return REJECTED_PROP
}

Expand Down Expand Up @@ -201,12 +191,11 @@ function matchesAllowedPrefix(url: URL, prefix: string): boolean {
}

/**
* Hard-floor check: does this string resolve to a known-unsafe URL scheme
* (`javascript:`, `data:text/html`, …)? Applied to binding-resolved values at
* render time so dot-path data (frontmatter/meta/data) cannot smuggle an
* unsafe URL past parse-time validation. Relative URLs are never unsafe here.
* Whether `value` is a known-unsafe URL.
*/
export function isUnsafeUrlValue(value: string): boolean {
if (!value.includes(':') && !value.includes('&') && !value.includes('%')) return false

let decoded = value
try {
decoded = decodeURIComponent(value)
Expand All @@ -221,7 +210,8 @@ export function isUnsafeUrlValue(value: string): boolean {
} catch {
return false
}
return hasUnsafeScheme(url)
// No prop mode here: a data image passes, as it cannot run script.
return isUnsafeProtocol(url, true)
}

// Attributes browsers navigate to or load as a URL, beyond the `*href` /
Comment thread
larbish marked this conversation as resolved.
Expand Down Expand Up @@ -254,11 +244,55 @@ function normalizePropName(attribute: string): string {
.replace(/[:_-]/g, '')
}

export function validateProp(attribute: string, value: unknown, options: PropsValidationOptions = {}): unknown {
const name = normalizePropName(attribute)
/** Whether prop `key` carries a URL, by the same names as `validateProp()`. */
export function isUrlPropKey(key: string): boolean {
return isUrlProp(normalizePropName(key))
}

/** Whether a normalized prop name is an event handler or a markup sink, whatever its value. */
function isUnsafePropName(name: string): boolean {
// `*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 name.startsWith('on') || name.endsWith('html') || unsafeAttributes.includes(name)
}

/** Props that pick the element or component a renderer creates. */
const tagSelectors = ['as', 'is']

/** Whether prop `key` is a tag selector: `as`, `is`, or one of their bindings. */
export function isTagSelector(key: string): boolean {
return tagSelectors.includes(normalizePropName(key))
}

/** A filter for tag selectors: `unsafeTags` and `blockedTags`, or any tag outside `allowedTags` when set. */
export function createTagFilter(blockedTags: string[] = [], allowedTags: string[] = []): (tag: string) => boolean {
const blocked = new Set([...blockedTags, ...unsafeTags].map((tag) => tag.toLowerCase()))
const allowed = new Set(allowedTags.map((tag) => tag.toLowerCase()))
return (tag) => {
const name = tag.toLowerCase()
return blocked.has(name) || (allowed.size > 0 && !allowed.has(name))
}
}

/**
* Whether tag selector `key` can make a renderer create a tag `isFilteredTag` rejects.
*/
export function isUnsafeTagSelector(key: string, value: unknown, isFilteredTag: (tag: string) => boolean): boolean {
if (typeof value !== 'string') return false
if (!/^(?::|v-bind:)/.test(key)) return isFilteredTag(value)

let decoded: unknown
try {
decoded = JSON.parse(value)
} catch {
return true
}
return typeof decoded === 'string' && isFilteredTag(decoded)
}

export function validateProp(attribute: string, value: unknown, options: PropsValidationOptions = {}): unknown {
const name = normalizePropName(attribute)
if (isUnsafePropName(name)) {
return REJECTED_PROP
}

Expand Down Expand Up @@ -286,16 +320,18 @@ export function validateProp(attribute: string, value: unknown, options: PropsVa
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)
function validateString(name: string, value: string, options: PropsValidationOptions): string | typeof REJECTED_PROP {
// 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)
}

function validateValue(name: string, value: unknown, options: PropsValidationOptions): unknown {
if (typeof value === 'string') return validateString(name, value, 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.
Expand Down Expand Up @@ -371,3 +407,66 @@ export function validateProps(

return props
}

export interface SanitizeDataOptions extends PropsValidationOptions {
/** Tags an `as` or `is` value may not name. */
blockedTags?: string[]
/** When set, the only tags an `as` or `is` value may name. */
allowedTags?: string[]
}

/**
* Remove unsafe entries from `data`, such as frontmatter, in place, at any depth.
*/
export function sanitizeData(data: unknown, options: SanitizeDataOptions = {}): string[] {
const isFilteredTag = createTagFilter(options.blockedTags, options.allowedTags)
const removed: string[] = []

const seen = new WeakMap<object, Set<string>>()

// `key` is undefined for an array item: it inherits its array's name, as in `validateProp()`.
const sanitizeEntry = (name: string, key: string | undefined, value: unknown): unknown => {
if (key !== undefined) {
if (isUnsafePropName(name)) return REJECTED_PROP
if (tagSelectors.includes(name) && isUnsafeTagSelector(key, value, isFilteredTag)) return REJECTED_PROP
}
return typeof value === 'string' ? validateString(name, value, options) : value
}

const walk = (value: object, path: string, name: string): void => {
const isArray = Array.isArray(value)
const seenAs = isArray ? name : ''
const names = seen.get(value)
if (names?.has(seenAs)) return
if (names) names.add(seenAs)
else seen.set(value, new Set([seenAs]))

let deleted = false
for (const [key, child] of Object.entries(value)) {
const at = path ? `${path}.${key}` : key
const childName = isArray ? name : normalizePropName(key)
const result = sanitizeEntry(childName, isArray ? undefined : key, child)
if (result === REJECTED_PROP) {
Reflect.deleteProperty(value, key)
removed.push(at)
deleted = true
} else if (result !== child) {
// `defaultOrigin` rewrote a URL.
;(value as Record<string, unknown>)[key] = result
} else if (child !== null && typeof child === 'object') {
walk(child, at, childName)
}
}

if (deleted && isArray) {
let length = 0
for (let index = 0; index < value.length; index++) {
if (index in value) value[length++] = value[index]
}
value.length = length
}
}
if (data !== null && typeof data === 'object') walk(data, '', '')

return removed
}
12 changes: 4 additions & 8 deletions packages/comark/src/internal/stringify/attributes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { stringifyYaml } from '../yaml.ts'
import { escapeHtml, get } from '../../utils/index.ts'
import { isUnsafeUrlValue } from '../props-validation.ts'
import { isUnsafeUrlValue, isUrlPropKey } from '../sanitize.ts'
import { pickFence } from './fence.ts'
import type { NodeRenderData } from '../../types.ts'

Expand Down Expand Up @@ -87,16 +87,12 @@ export function resolveAttributes(
outValue = value
}

// Hard floor: a binding must never resolve href/src to an unsafe scheme
// Hard floor: a binding must never resolve a URL prop to an unsafe scheme
// (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.
if (
isBinding &&
(lowerOutKey === 'href' || lowerOutKey === 'src' || lowerOutKey === 'xlink:href' || lowerOutKey === 'to') &&
typeof outValue === 'string' &&
isUnsafeUrlValue(outValue)
) {
// The URL props are the ones `validateProp()` checks: `:link="frontmatter.x"` counts too.
if (isBinding && isUrlPropKey(outKey) && typeof outValue === 'string' && isUnsafeUrlValue(outValue)) {
continue
}

Expand Down
Loading
Loading