---
title: "Show untrusted text safely and protect web responses"
description: "Sanitize user HTML and model Markdown, find raw HTML sinks, and send secure headers, CSRF checks, CORS rules and safe cookies from the inbound section."
section: "guides"
status: "Planned for 0.2"
spec: "sanitize"
canonical: "https://mainsdk.dev/docs/guides/sanitize-and-protect-inbound"
updated: "2026-10-28"
---

# Show untrusted text safely and protect web responses

> Sanitize user HTML and model Markdown, find raw HTML sinks, and send secure headers, CSRF checks, CORS rules and safe cookies from the inbound section.

Status: Planned for 0.2 · Canonical: https://mainsdk.dev/docs/guides/sanitize-and-protect-inbound · Updated: 2026-10-28

You will make two things safe: the text users and models give you, and the responses your app sends back. `sanitize` turns untrusted HTML or Markdown into HTML that cannot run script. `inbound` wraps your handler with a strict Content Security Policy, CSRF checks, a CORS allowlist, safe cookie flags and `security.txt`, from one `inbound` section of `main.yaml`. Neither has its own interface page: both are in `mainsdk` and run in your app, with no dependency and no network call. It takes about 25 minutes.

> **Note:** `sanitize` (spec `sanitize`) and `inbound` (spec `inbound-protection`) are in 0.2 and run from the repository; they are not published to npm. There is no Next.js, Hono or Express adapter yet: today you wrap a Web-standard handler with `inbound.handle()`, which is the code every adapter will call. Both specs are drafts. Bot defence and rate limits are separate: see [rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits).

## You need

- A MainSDK app with a `main.yaml` (see [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes)).
- A handler that takes a Web `Request` and returns a `Response`.

## 1. Sanitize HTML a user wrote

```ts title="lib/comments.ts"
import { sanitize } from "mainsdk";

export function renderComment(html: string) {
  return sanitize.html(html);   // the "basic" profile
}

// sanitize.html('<p onclick="x()">Hi <b>there</b><script>alert(1)</script><a href="https://acme.dev" style="color:red">link</a></p>')
// => <p>Hi <b>there</b><a href="https://acme.dev" rel="noopener noreferrer nofollow ugc">link</a></p>
```

You should see the script, the `onclick` and the `style` gone, and the link kept with a safe `rel`. The output is rebuilt from an allowlist and escaped, so sanitizing it again gives the same string.

Choose a profile and narrow what links and images may point to:

```ts title="lib/posts.ts"
import { sanitize } from "mainsdk";

export function renderPost(html: string) {
  return sanitize.html(html, {
    profile: "rich",                                  // text | basic | rich
    images: { hosts: ["cdn.acme.dev"] },              // rich only; "self" is the page's origin
    links: { hosts: ["acme.dev"], target: "_blank" }, // other hosts become plain text
    ids: "prefix",                                    // id="user-content-<id>" instead of dropping ids
  });
}
```

| Profile | Keeps |
| --- | --- |
| `text` | Nothing: all markup removed, text kept and escaped |
| `basic` (default) | `p br b strong i em u s code pre blockquote ul ol li a` |
| `rich` | `basic` plus headings, tables, `img`, `figure`, `details`, `span`, `div` and a few more |

Never allowed, whatever you pass: `script`, `style`, `iframe`, `object`, `svg`, `math`, `form`, any `on*` attribute, `style` and `srcdoc` attributes, and the schemes `javascript:`, `vbscript:` and `data:` for links. Asking for one throws `MAIN-SANITIZE-0002`. Input over 1 MB (`maxBytes`) or nested deeper than 100 elements throws `MAIN-SANITIZE-0001` and returns nothing.

## 2. Render model output as Markdown

Model output is untrusted input. `sanitize.markdown()` renders it to safe HTML and, with `source: "ai"`, draws no images and links only to hosts you name, so a prompt-injected answer cannot send data out through an image URL.

```ts title="lib/answer.ts"
import { sanitize } from "mainsdk";

export function renderAnswer(markdown: string) {
  return sanitize.markdown(markdown, { source: "ai", links: { hosts: ["docs.acme.dev"] } });
}
```

You should see a link to `docs.acme.dev` as an anchor, any other link as its text followed by the URL in plain text, raw `<script>` shown as escaped text, tables as `table`, and fenced code as `<pre><code class="language-ts">`. `ai.html(text, opts)` is the same call with `source: "ai"` set for you.

## 3. Build HTML by hand when you must

```ts title="lib/card.ts"
import { sanitize } from "mainsdk";

export function card(name: string, site: string) {
  const href = sanitize.url(site, { hosts: ["acme.dev"] });   // a clean URL, or null when it is unsafe
  return `<a title="${sanitize.attr(name)}" ${href ? `href="${href}"` : ""}>${sanitize.text(name)}</a>`;
}
```

`sanitize.text()` escapes for a text node, `sanitize.attr()` for a double-quoted attribute value, and `sanitize.url()` returns `null` for `javascript:` and other unsafe URLs.

## 4. Let the check find raw HTML sinks

`mainsdk check` has a `sanitize` gate. It reports `MAIN-SANITIZE-0003` for `dangerouslySetInnerHTML`, `innerHTML`, `outerHTML`, `insertAdjacentHTML`, `document.write`, Vue `v-html` and Svelte `{@html}` fed by a value that did not come from `sanitize.html`, `sanitize.markdown` or `ai.html`.

```ts title="src/comment.ts"
export function show(el: { innerHTML: string }, body: string) {
  el.innerHTML = body;   // MAIN-SANITIZE-0003
}
```

```text
high  MAIN-SANITIZE-0003  src/comment.ts:2  innerHTML is fed by a value that did not come from sanitize
      fix: Pass the value through sanitize.html(value), or sanitize.markdown(value) for Markdown, from mainsdk/sanitize first
```

A literal string with no interpolation, and a `const` in the same function assigned from `sanitize.html()`, are not reported.

## 5. Wrap your handler with inbound protection

Declare what you need under `inbound` in `main.yaml`. With no section at all you already get the defaults below.

```yaml title="main.yaml"
app:
  url: https://app.acme.dev
inbound:
  csp:
    img: [self, "data:", https://getqr.id, https://cdn.acme.dev]
  cors:
    origins: [https://app.acme.dev, "https://*.acme.dev"]
    credentials: true
    methods: [GET, POST]
    headers: [content-type, authorization]
    max-age: 10m
  csrf:
    exempt: [/api/webhooks/*]
  cookies:
    readable: [theme]
  security-txt:
    contact: [mailto:security@acme.dev]
    expires: 2027-10-01
```

```ts title="server.ts"
import { inbound } from "mainsdk";

async function app(req: Request): Promise<Response> {
  const nonce = inbound.nonce();   // the CSP nonce of this response, for <script nonce={nonce}>
  return new Response(`<script nonce="${nonce}">console.log("ok")</script>`, {
    headers: { "content-type": "text/html" },
  });
}

export default { fetch: (req: Request) => inbound.handle(req, app) };
```

`inbound.handle(req, next)` runs every protection around `next`. Outside a request, `inbound.nonce()` and `inbound.csrfToken()` throw `MAIN-WEB-0013`.

What every response gets, in production over HTTPS:

| Header | Default |
| --- | --- |
| `Content-Security-Policy` | `default-src 'self'`; scripts by a fresh nonce plus `'strict-dynamic'`; `object-src 'none'`, `base-uri 'none'`, `frame-ancestors 'self'`, `upgrade-insecure-requests` |
| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains`; never in `dev` or over HTTP |
| `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy` | `nosniff`, `SAMEORIGIN`, `strict-origin-when-cross-origin`, camera, microphone and geolocation off, `same-origin` |

`X-Powered-By` is removed. A handler that sets one of these headers itself is overridden, and `MAIN-WEB-0004` is logged once per route and header. A route that needs a different value gets an entry under `inbound.routes`:

```yaml title="main.yaml"
inbound:
  routes:
    "/embed/*": { frame-ancestors: ["https://*.partner.com"] }
```

Check what is in force for any path:

```ts title="scripts/show-policy.ts"
import { inbound } from "mainsdk";

console.log(inbound.policy("/embed/widget"));   // headers, csp, cors, csrf exemptions, cookie rules, security.txt
```

### CSRF

A request with an unsafe method (`POST`, `PUT`, `PATCH`, `DELETE`) and a `Cookie` header must be same-origin by Fetch Metadata, come from an origin in `cors.origins` with `credentials: true`, or carry a valid token. Anything else is refused with 403 `MAIN-WEB-0002` before your handler runs. A request with no cookie (an API key or bearer token) is not checked. For a form posted by a browser that sends no Fetch Metadata, send the token:

```ts title="app/form.ts"
import { inbound } from "mainsdk";

export function formField() {
  return `<input type="hidden" name="_csrf" value="${inbound.csrfToken()}">`;   // or send it as x-csrf-token
}
```

Signed webhooks take cross-site posts by design: list their paths under `csrf.exempt`. Declared inbound webhook endpoints are exempted for you.

### CORS, cookies and security.txt

- With no `cors` block, no cross-origin request is answered. Origins are exact or `https://*.domain`; `*` with credentials fails the check.
- Every `Set-Cookie` leaves with `Secure` (outside `dev`), `SameSite` (default `Lax`), `Path=/` and `HttpOnly`, unless its name is under `cookies.readable`. The fix is logged once per cookie name (`MAIN-WEB-0008`).
- With `security-txt`, `GET /.well-known/security.txt` is served per RFC 9116 and `/security.txt` redirects to it.
- To try a stricter policy first, set `inbound.csp.mode: report`. It sends `Content-Security-Policy-Report-Only` and takes violation reports at `POST /mainsdk/csp-report` (204, logged as `MAIN-WEB-0006` without the query string). Move to `enforce` when the reports are quiet.
- `headers.coep` and `csp.trusted-types` are opt-in. `Cross-Origin-Embedder-Policy: require-corp` breaks cross-origin images and scripts that send no CORP or CORS headers.

`inbound` values change only in a reviewed deploy; they cannot be pushed at run time.

## Verify

```bash
npx mainsdk check --env prod
```

A pass has no `MAIN-SANITIZE` or `MAIN-WEB` finding. The `web` gate reads the `inbound` section and your cookie code; the `sanitize` gate reads your source. In a test, call `inbound.handle()` with a `Request` and assert on the headers and status.

## If it fails

| Code | Cause | Fix |
| --- | --- | --- |
| `MAIN-SANITIZE-0001` | Input over `maxBytes` (1 MB by default) or nested deeper than 100 elements. | Send less, or raise `maxBytes`. |
| `MAIN-SANITIZE-0002` | An option asks for a never-allowed tag, attribute or scheme. | Remove it from the options. |
| `MAIN-SANITIZE-0003` | Raw HTML reaches the page without `sanitize`. | Pass the value through `sanitize.html()` or `sanitize.markdown()`. |
| `MAIN-WEB-0001` | `cors.origins` has `*` with `credentials: true` (or `*` at all with `--env prod`). | List the origins that may call you. |
| `MAIN-WEB-0002` | A cross-site unsafe request carried a cookie and no valid token (403). | Send it from your own site, add the origin to `cors.origins` with credentials, or list a signed webhook under `csrf.exempt`. |
| `MAIN-WEB-0003` | An `inbound` value is invalid: an unknown key, a bad origin or duration. | Fix the key the finding names. |
| `MAIN-WEB-0005` | A CORS preflight from an origin, method or header not allowed (403). | Add it to `cors`. |
| `MAIN-WEB-0007` | `csp.script` allows `unsafe-inline` or `unsafe-eval`. | Use `nonce` and `strict-dynamic`. |
| `MAIN-WEB-0009` | Code sets `SameSite=None` without `Secure`, or `httpOnly: false` for a cookie not under `cookies.readable`. | Add `Secure`, or list the cookie. |
| `MAIN-WEB-0010` | `security-txt` has no contact, or `expires` is missing, past or over a year ahead. | Fix the contact or date. |
| `MAIN-WEB-0013` | `inbound.nonce()` or `csrfToken()` called outside a request (500). | Call it while a handler runs through `inbound.handle()`. |

## For agents

```text
Goal: show untrusted text safely and send protective headers.
Read: docs/specs/sanitize.md, docs/specs/inbound-protection.md, this page, main.yaml.
Touch only: main.yaml (inbound section), files that render HTML, the server entry that wraps the handler.
Do: pass every user or model string that reaches the page as HTML through sanitize.html or sanitize.markdown
(source: "ai" for model output); wrap the handler with inbound.handle; set cors.origins to exact origins; list signed
webhook paths under csrf.exempt; add security-txt for production.
Verify: npx mainsdk check --env prod --json
Never allow unsafe-inline or unsafe-eval, add "*" to cors.origins, or silence a MAIN-SANITIZE or MAIN-WEB finding to make the check pass.
```

## Next

- [Restrict which hosts and IPs your app can reach](https://mainsdk.dev/docs/guides/restrict-network): the outbound side.
- [Rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits): throttle abusive clients.
- [Protect a file so only the right people open it](https://mainsdk.dev/docs/guides/protect-an-app): keep control of a file after you share it.
