Skip to main content

Security

There are two surfaces. The renderer takes Markdown that an author wrote. The template engine also takes data that a runtime produced. They have different threat models, so they are documented separately.

Renderer

Raw HTML is not executed

The default is to render raw HTML as visible escaped text. Nothing runs.

Hostile source, default settings
Markdown
Hello <img src=x onerror="alert(1)"> world.

<script>alert('nope')</script>

[Click me](javascript:alert(1))
Rendered

Hello <img src=x onerror="alert(1)"> world.

<script>alert('nope')</script>

Click me

The tags appear as text. The javascript: destination is emptied, so the link has nothing to navigate to.

skipHtml controls presentation, not safety.

skipHtmlResult
false (default)Raw HTML becomes visible text
trueRaw HTML is removed

Neither executes it. The default matches react-markdown, because removing content the author wrote is silent data loss, and both options are equally safe.

URL policy

Every URL-bearing attribute passes through urlTransform before rendering. The default algorithm allows http, https, irc, ircs, mailto and xmpp, and empties everything else. A colon appearing after the first /, ? or # is part of a path, so relative URLs are untouched.

import { defaultUrlTransform } from '@react-markdown-kit/renderer'

<Markdown
urlTransform={(url, key, node) =>
url.startsWith('/') ? url : defaultUrlTransform(url)
}
>
{source}
</Markdown>

The policy runs after every plugin, so a plugin cannot inject markup that skips it.

Element policy

<Markdown allowedElements={['p', 'strong', 'em', 'a']}>{source}</Markdown>
<Markdown disallowedElements={['img']} unwrapDisallowed>{source}</Markdown>

Use one of allowedElements or disallowedElements, never both. allowElement takes a predicate for anything more specific. unwrapDisallowed keeps the children of a removed element instead of dropping them.

Allowing raw HTML on purpose

Some content genuinely needs HTML. Opt in explicitly, and sanitize in the same breath.

import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'

<Markdown skipHtml={false} rehypePlugins={[rehypeRaw, rehypeSanitize]}>
{content}
</Markdown>

Order matters. rehype-raw parses the HTML into real nodes, and rehype-sanitize then strips what its schema forbids. Running them the other way round sanitizes nothing.

Plugins and components are trusted code

A remark plugin, a rehype plugin and a component you pass to components are application code. They are not sandboxed, and the kit does not audit what they produce. The content policy runs after plugins, so it still filters their output, but a component that renders dangerouslySetInnerHTML is entirely your decision.

The kit makes no claim beyond that. It is not a sanitizer, and it is not a substitute for a Content Security Policy.

Templates

Template data is the harder problem, because an author's Markdown and a runtime's data meet in the same document.

Values are placed, never substituted

Resolution is not string replacement followed by parsing. The source is parsed first, and values are placed structurally into the parsed tree. A value is always a text node, so its Markdown punctuation is characters.

Data that tries to be structure
Authored template never changes
## Access review for {{account.name}}

Reviewer note: {{note}}
Resolved for Ordinary note changes

Access review for Acme

Reviewer note: Nothing unusual this quarter.

Data passed to template()
{
  "account": {
    "name": "Acme"
  },
  "note": "Nothing unusual this quarter."
}

The third dataset renders as one line of text inside the paragraph. No table appears, in this document or in a re-parse of its serialized Markdown.

No value can create a heading, a table row, a list item, a link destination, an HTML tag or a code fence.

Every newline in a value becomes a space

This is a security boundary, not formatting. A placeholder always sits in inline context, but a serializer will happily write a value's newline as a real line break. Every dangerous block construct needs only line-start position to form. A table row, a list bullet, an ATX heading, a fence and a thematic break all qualify, and none of them needs a blank line.

So a value containing \n| x | y |\n| --- | --- | once resolved to a safe single-paragraph document, serialized with those breaks intact, and then re-parsed as a real table. The attacker's table materialized one hop downstream, in exactly the persist-then-render pipeline this package exists to serve.

Flattening closes that. Every character of the value survives and only newlines become spaces, so no character of a value can reach column one. A value that genuinely needs line structure is a block-level feature, not something inline data should smuggle through. The rule is implemented in packages/renderer/src/template/interpolate.ts and pinned by tests/template-serialization-safety.test.ts.

Code contexts and escaped delimiters stay literal

A placeholder inside inline code or a fenced block is never resolved. It is documentation of the template, so it renders as itself.

`{{user.name}}`

```txt
{{user.name}}
```

A backslash escape does the same in prose:

\{{user.name}}

Raw HTML is a literal context too. A placeholder inside an HTML block stays unresolved and reports TEMPLATE_PLACEHOLDER_IN_HTML, so data can never fill an attribute.

Paths cannot reach the prototype chain

A path segment of __proto__, constructor or prototype is rejected with TEMPLATE_UNSAFE_PATH and never traversed. Lookup reads own enumerable properties only, so no inherited getter is ever invoked.

There is no expression language. No method calls, no function calls, no eval, no new Function, and no access to application services. Runtime data never gains template-language privileges.

URLs are bound whole

A destination binds a complete URL and nothing less.

[Open account]({{links.accountUrl}})

![{{brand.logoAlt}}]({{brand.logoUrl}})

Half a URL is refused with TEMPLATE_PARTIAL_URL, because correct encoding of a fragment cannot be guaranteed. Build the URL in your application and bind the result.

const data = { links: { accountUrl: buildAccountUrl(customer) } }

A bound destination is checked against the safe-protocol list, and whitespace or control characters reject it outright. That check is isSafeDestination, and it is exported. The renderer then applies its own URL policy again on the way to HTML.

Diagnostics carry paths, not values

TEMPLATE_REQUIRED_VALUE
Missing required variable: customer.accountNumber

Messages name the variable path and the source position. They never include the runtime value, so a diagnostic is safe to log and safe to show a support agent.

Caching resolved output

Resolution is per customer. Caching it wrongly is how one tenant's document reaches another.

Safe to cache by template identity alone:

  • the parsed template;
  • the compiled grammar;
  • the static source tree.

template() already does this internally, and the renderer parses a fresh tree for every resolution, so nothing derived from one caller's data survives into another's.

Resolved output is different. Key it by everything that produced it, or keep it request-local.

const key = [template.id, template.version, tenantId, dataVersion, locale, timeZone].join('|')

Never do this:

report template ID → resolved Acme document

The next request may be for another customer. If a correct key is hard to build, do not cache the output. Cache the template and resolve per request, which is the cheap half of the work anyway.

Reporting

Security issues go to the repository's security contact rather than a public issue. Test coverage for these behaviours lives in tests/ and in packages/renderer/tests/template/, including protocol obfuscation, event attributes, plugin-generated HTML, DOM clobbering and serialization round trips.