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.
Hello <img src=x onerror="alert(1)"> world.
<script>alert('nope')</script>
[Click me](javascript:alert(1))
Hello <img src=x onerror="alert(1)"> world.
<script>alert('nope')</script>The tags appear as text. The javascript: destination is emptied, so the
link has nothing to navigate to.
skipHtml controls presentation, not safety.
skipHtml | Result |
|---|---|
false (default) | Raw HTML becomes visible text |
true | Raw 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.
## Access review for {{account.name}}
Reviewer note: {{note}}
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}})

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.