MDX safety

Let writers and agents edit content without letting them run code.

Content7 min read

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.

graft.config.ts
export const mdxTrust = "restricted"; // the default

Under "restricted", a body may be prose, Markdown, GFM, and components with literal string attributes. Everything that evaluates is refused.

What is refused

ConstructExample
A {…} expression{process.env.SECRET}
An import or exportimport 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.

SurfaceWhy it checks
graft compileContent already in git, so a bad document fails the build rather than a request
MCP write_contentContent arriving from someone who is not the operator, refused before it is stored
Studio savesSame
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

graft.config.ts
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.