# MDX safety

> MDX can run code. Graft refuses the parts that run by default, so anyone who can write content cannot run code on your server.

*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](/docs/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`:

```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

| 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.

> **Next.js, Vue, Nuxt and SvelteKit only.**
> The render-time row is `MdxBody` and `prepareMdx` in `@usegraft/sdk-next`,
> `prepareMdx` in `@usegraft/sdk-vue` (which `@usegraft/sdk-nuxt` re-exports
> for its server routes), and `prepareMdx` in `@usegraft/sdk-sveltekit`, which
> refuses executable MDX under every trust level because its renderer never
> evaluates the body. **No other adapter has a
> render-time check.** Astro, TanStack Start and React Router hand you the
> authored source and you render it with your own pipeline, which is where that
> responsibility then sits. This documentation site is one of them.
>
> So on those stacks the write-side guards and `graft compile` are the whole
> defence, and the case the render check exists for (content inserted straight
> into the index with the runtime credential, which no write-side guard sees)
> is one your own renderer has to answer. `assertSafeMdx` from
> `@usegraft/mdx-safety` is exported for exactly that.

### 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:

```text
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:

```text
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`:

```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:

```tsx
<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:

```mdx
<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.

---

<details>
<summary><strong>Deep dive: Why Graft validates the AST instead of using a VM sandbox</strong></summary>

### 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`).
</details>

## Next steps

- [Configuration](/docs/configuration#mdxtrust) documents the setting in context.
- [Studio](/docs/studio) is where an author is not the operator.
- [MCP tools](/docs/mcp-tools#write_content) documents the write path this guards.
- [Schema](/docs/schema) is the other half of what a document must satisfy.
