Security
There are two separate things to think about here. The renderer takes Markdown that an author wrote, and the variables plugin also takes data produced at runtime. Their threat models are different, so they're covered separately below.
Renderer#
Raw HTML is not executed#
By default, raw HTML renders as visible escaped text and nothing in it 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 show up as text. The javascript: destination gets emptied, so the
link doesn't go anywhere.
skipHtml only changes how raw HTML is presented. It doesn't affect safety.
skipHtml | Result |
|---|---|
false (default) | Raw HTML becomes visible text |
true | Raw HTML is removed |
Neither option executes it. The default matches react-markdown. Both options
are equally safe, and removing content the author wrote would be silent data
loss, so we kept the visible-text behaviour as the default.
URL policy#
Every attribute that holds a URL goes through urlTransform before rendering.
The default algorithm allows http, https, irc, ircs, mailto and xmpp,
and empties everything else. A colon that shows up after the first /, ? or
# counts as part of a path, so relative URLs are left alone.
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 can't inject markup that gets around it.
Element policy#
<Markdown allowedElements={['p', 'strong', 'em', 'a']}>{source}</Markdown>
<Markdown disallowedElements={['img']} unwrapDisallowed>{source}</Markdown>Pick either allowedElements or disallowedElements (don't use both). For
anything more specific, allowElement takes a predicate. unwrapDisallowed
keeps the children of a removed element instead of dropping them.
Allowing raw HTML on purpose#
Some content really does need HTML. In that case, opt in explicitly and add a sanitizer at the same time.
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
<Markdown skipHtml={false} rehypePlugins={[rehypeRaw, rehypeSanitize]}>
{content}
</Markdown>The order matters. rehype-raw parses the HTML into real nodes, and then
rehype-sanitize strips whatever its schema forbids. If you run them the other
way round, nothing gets sanitized.
Plugins and components are trusted code#
Remark plugins, rehype plugins and anything you pass to components count as
application code. They aren't sandboxed, and the kit doesn't audit what they
produce. The content policy runs after plugins, so it still filters their
output, but if one of your components renders dangerouslySetInnerHTML, that's
on you.
The kit doesn't claim anything beyond that. It isn't a sanitizer, and you should still have a Content Security Policy.
Variables#
Variable data is harder to get right, since an author's Markdown and data from your runtime end up in the same document.
Values go into the parsed tree#
Resolving variables doesn't do string replacement and then parse the result. The source gets parsed first, and values are placed into the parsed tree. A value is always a text node, so any Markdown punctuation in it stays as plain characters.
## Access review for {{account.name}}
Reviewer note: {{note}}
Access review for Acme
Reviewer note: Nothing unusual this quarter.
The third dataset renders as one line of text inside the paragraph. No table shows up, either in this document or if you re-parse its serialized Markdown.
A value can't 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 rule is about security. A placeholder always sits in inline context, but a serializer will happily write a value's newline out as a real line break. Every dangerous block construct only needs to be at the start of a line 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.
At one point, a value containing \n| x | y |\n| --- | --- | resolved
to a safe single-paragraph document, got serialized with those breaks intact,
and then re-parsed as a real table. The attacker's table showed up one step
later, in the persist-then-render pipeline this package is meant for.
Flattening newlines fixes that. Every character of the value is kept and only
newlines turn into spaces, so nothing in a value can reach column one. If a
value really needs line structure, that should be a block-level feature instead
of something inline data sneaks through. The rule is implemented in
plugins/variables/src/engine/interpolate.ts and pinned by
tests/variables-serialization-safety.test.ts.
Code contexts and escaped delimiters stay literal#
A placeholder inside inline code or a fenced block is never resolved. It's treated as documentation of the placeholder syntax, so it renders as written.
`{{user.name}}`
```txt
{{user.name}}
```A backslash escape does the same thing in prose:
\{{user.name}}Raw HTML is a literal context too. A placeholder inside an HTML block stays
unresolved and reports VARIABLE_PLACEHOLDER_IN_HTML, so data can't end up in an
attribute.
Paths cannot reach the prototype chain#
A path segment of __proto__, constructor or prototype gets rejected with
VARIABLE_UNSAFE_PATH and is never traversed. Lookup only reads own enumerable
properties, so inherited getters never get called.
There's no expression language. Placeholders can't call methods or functions, use
eval or new Function, or reach application services, and runtime data
never gets expression-language privileges.
URLs are bound whole#
A destination has to bind a complete URL.
[Open account]({{links.accountUrl}})
Binding part of a URL is refused with VARIABLE_PARTIAL_URL, because there's no
way to guarantee a fragment gets encoded correctly. Build the URL in your
application and bind the result instead.
const data = { links: { accountUrl: buildAccountUrl(customer) } }A bound destination is checked against the safe-protocol list, and any
whitespace or control characters get it rejected outright. That check is
isSafeDestination, which is exported. The renderer then applies its own URL
policy again on the way to HTML.
Diagnostics leave out values#
VARIABLE_REQUIRED_VALUE
Missing required variable: customer.accountNumberMessages name the variable path and the source position. They never include the runtime value, so it's safe to log a diagnostic or show it to a support agent.
Caching resolved output#
Resolved output is per customer. If you cache it wrong, one tenant's document can end up in front of another tenant.
These are safe to cache by source identity alone:
- the parsed source;
- the compiled grammar;
- the static source tree.
variables() already does this internally, and the renderer parses a fresh tree
for every resolution, so nothing from one caller's data carries over to another's.
Resolved output needs more care. Either key it by everything that went into producing it, or keep it request-local.
const key = [documentId, documentVersion, tenantId, dataVersion, locale, timeZone].join('|')Don't do this:
report document ID → resolved Acme documentThe next request could be for a different customer. If a correct key is hard to build, I'd skip caching the output. Cache the parsed source and resolve per request (that's the cheap part of the work anyway).
Reporting#
Please send security issues to the repository's security contact instead of
opening a public issue. Test coverage for these behaviours lives in tests/ and in
plugins/variables/tests/, including protocol obfuscation, event attributes,
plugin-generated HTML, DOM clobbering and serialization round trips.
Last updated on