MainSDK
Search⌘K
How-to

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.

Planned for 0.2

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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.

You need

  • A MainSDK app with a main.yaml (see Thirty-minute path).
  • A handler that takes a Web Request and returns a Response.

1. Sanitize HTML a user wrote

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:

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
  });
}
ProfileKeeps
textNothing: all markup removed, text kept and escaped
basic (default)p br b strong i em u s code pre blockquote ul ol li a
richbasic 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.

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

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.

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.

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

HeaderDefault
Content-Security-Policydefault-src 'self'; scripts by a fresh nonce plus 'strict-dynamic'; object-src 'none', base-uri 'none', frame-ancestors 'self', upgrade-insecure-requests
Strict-Transport-Securitymax-age=63072000; includeSubDomains; never in dev or over HTTP
X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policynosniff, 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:

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

Check what is in force for any path:

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:

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

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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet