MDX safety
Let writers and agents edit content without letting them run code.
MDX is Markdown that can include components. It can also run code. A
{expression} in an MDX file runs JavaScript when the page renders, and an
import can load any module. That code runs on your server, with
access to process.env, fetch, and everything else your app can reach.
When you are the only author and every change goes through review in Git, that power is useful. It becomes a risk as soon as someone else can write content: a writer using Studio, or an agent writing over MCP. Without a check, "can edit a page" would mean "can run code on your server".
So, by default, Graft checks MDX on every path it writes or compiles:
write_content, Studio saves, and graft compile. It refuses anything that
can run code. A body written straight into the database skips the write-time
check. In Next.js, MdxBody checks again when it renders, so even that body is
refused there. Plain Markdown and components with plain text attributes still
work. Setting mdxTrust = "full" (or trust="full" on MdxBody) turns the
check off for that path. This page lists what is refused, where the check runs,
and how to allow full MDX for content you write yourself.
What is protected by default
mdxTrust defaults to "restricted", so you get this without configuring
anything.
export const mdxTrust = "restricted"; // the defaultUnder "restricted", a body may be prose, Markdown, GFM, and components with
literal string attributes. Everything that evaluates is refused.
What is refused
| Construct | Example |
|---|---|
A {…} expression | {process.env.SECRET} |
An import or export | import x from "y" |
| An attribute whose value is an expression | <Foo bar={value} /> |
| A spread attribute | <Foo {...props} /> |
| An element that can load or run code | <script>, <iframe>, <object>, <embed>, <frame>, <frameset>, <base> |
| An inline event handler | <img onerror="…" /> |
| A raw-HTML attribute, in any case | <div innerHTML="…" />, outerHTML, dangerouslySetInnerHTML |
The last three matter more than they look. <script>alert(1)</script> contains
no expression at all, and MDX renders an unknown lowercase tag straight through
to HTML. An onerror with a plain string value still runs in a browser, because
React writes an unrecognized attribute into the markup as-is. And Vue renders a
literal innerHTML="<img src=x onerror=…>" as markup, so that attribute is
refused even with a plain string value.
Frontmatter is read as YAML or JSON
The block between the opening and closing --- is data, and Graft reads it as
YAML, or as JSON when it opens with ---json. A block that names any other
language, such as ---js, is refused with INPUT_VALIDATION_FAILED before any
of it is read. A JavaScript block would run as code on the machine reading the
file: the Studio server for a contributor's save, the MCP server for an agent's
write, or CI for a pull request.
This holds whatever mdxTrust is set to. Graft writes YAML frontmatter itself,
so the fix is to rewrite the block as YAML.
Where Graft checks
Three places, and they are checking different things.
| Surface | Why it checks |
|---|---|
graft compile | Content already in git, so a bad document fails the build rather than a request |
MCP write_content | Content arriving from someone who is not the operator, refused before it is stored |
| Studio saves | Same |
MdxBody at render, and prepareMdx (sdk-next, sdk-vue, sdk-nuxt and sdk-sveltekit) | Content can also arrive by a direct database write with the runtime credential, which no write-side guard sees |
The compile check is what makes the failure a build-time one. Without it,
MdxBody refusing at render would mean an unchecked tree fails per-request in
production instead.
Compile reports all of them
Refusals are collected rather than thrown per file. Within one body, every executable construct is listed; across the tree, every offending document is. An author fixing one at a time learns the rule slowly and resents it.
One offender still fails the whole compile, so nothing is projected until the tree is clean. This is a tree-wide gate, not a per-document one.
The errors
Both are INPUT_VALIDATION_FAILED. They differ in what they can name.
Compile names the documents, up to ten of them, then counts the rest:
Executable MDX in 3 document(s): docs/pricing.mdx ({price}),
pages/home.mdx (<script>), docs/faq.mdx ({items.map}).
A single write (a write_content call, or a Studio save) names the constructs
instead, with kind, line, and a truncated snippet for up to five, then counts
the rest:
Executable MDX is not accepted in docs/pricing. Found a `{…}` expression
(line 12): {price}.
An unparseable document gets its own message, because the checker cannot vouch for source it could not read.
Allowing full MDX for content you trust
export const mdxTrust = "full";Set this only when every author of the repository has commit access, because then code review is the control and MDX is just part of the source.
MdxBody has its own trust prop with the same default, and the two must
agree:
<MdxBody source={doc.body} trust="full" components={mdxComponents} />
Setting mdxTrust = "full" and leaving the render site on the default indexes
bodies that then fail to render. Compile says so in the error when it refuses a
document.
Anything other than the two known values is refused rather than defaulted, with
CONFIG_INVALID. A typo would otherwise silently re-enable the restriction
somebody was deliberately turning off, or the reverse.
Limitations: it is not an HTML sanitizer
It refuses constructs that execute. It is not an HTML sanitizer.
It does not filter style attributes, srcdoc, or the long tail of markup that
can be abused without running script inline. If you render content from people
you do not trust at all, render it with your own pipeline and rehype-sanitize.
This check closes the MDX-specific hole a Markdown sanitizer would not even see.
One filter does apply to restricted bodies: MdxBody, and prepareMdx in
@usegraft/sdk-next, @usegraft/sdk-vue and @usegraft/sdk-sveltekit, drop any
href, src or other URL attribute whose scheme is not http, https,
mailto or tel, on Markdown links and on components alike. A
[Read more](javascript:…) link keeps its text and loses the URL. React 19
blocks javascript: URLs on its own. React 18, which the Pages Router can run
on, does not, and neither do Vue and Svelte.
Writing components that pass
Components are fully supported as long as attributes are literal strings:
<Callout label="Note">
Git is authoritative for authored content.
</Callout>
Tips for components in MDX
- Place each JSX tag on its own line. Markdown parsers treat JSX as a distinct block only when no markdown syntax shares its opening tag line.
- Leave blank lines around nested prose. Ensure empty lines exist before and after markdown lists or code blocks nested inside JSX components.
- Keep components outside headings. Adding a component inside an
##heading modifies its slug anchor id and can break table-of-contents links.
Deep dive: Why Graft validates the AST instead of using a VM sandbox
Why not a sandbox?
Removing the executable surface at input validation was chosen over containing
it at runtime. node:vm is explicitly not a security boundary, and running a
worker thread sandbox cannot return real React elements back across the thread
boundary without breaking component reference identity under React Server
Components (RSC). What can be guaranteed safe is the input AST itself.
Refusal of unparseable source
Source that the checker cannot parse is refused rather than passed through.
Assuming that unparseable source cannot execute is unsafe because independent
parsers can drift. The checker parses with remark-gfm to guarantee parity
with the runtime compiler.
Executable vs HTML sanitization
Graft's MDX gate specifically prevents arbitrary server-side code execution.
If your application accepts content from untrusted external users, you should also
incorporate rehype-sanitize into your render pipeline to strip unwanted HTML
attributes (e.g. style or srcdoc).
Next steps
- Configuration documents the setting in context.
- Studio is where an author is not the operator.
- MCP tools documents the write path this guards.
- Schema is the other half of what a document must satisfy.