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 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.
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
Requestand returns aResponse.
1. Sanitize HTML a user wrote
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:
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.
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
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.
export function show(el: { innerHTML: string }, body: string) {
el.innerHTML = body; // MAIN-SANITIZE-0003
}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 firstA 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.
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-01import { 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:
inbound:
routes:
"/embed/*": { frame-ancestors: ["https://*.partner.com"] }Check what is in force for any path:
import { inbound } from "mainsdk";
console.log(inbound.policy("/embed/widget")); // headers, csp, cors, csrf exemptions, cookie rules, security.txtCSRF
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:
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
corsblock, no cross-origin request is answered. Origins are exact orhttps://*.domain;*with credentials fails the check. - Every
Set-Cookieleaves withSecure(outsidedev),SameSite(defaultLax),Path=/andHttpOnly, unless its name is undercookies.readable. The fix is logged once per cookie name (MAIN-WEB-0008). - With
security-txt,GET /.well-known/security.txtis served per RFC 9116 and/security.txtredirects to it. - To try a stricter policy first, set
inbound.csp.mode: report. It sendsContent-Security-Policy-Report-Onlyand takes violation reports atPOST /mainsdk/csp-report(204, logged asMAIN-WEB-0006without the query string). Move toenforcewhen the reports are quiet. headers.coepandcsp.trusted-typesare opt-in.Cross-Origin-Embedder-Policy: require-corpbreaks 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
npx mainsdk check --env prodA 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
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: the outbound side.
- Rate limits and quotas: throttle abusive clients.
- Protect a file so only the right people open it: keep control of a file after you share it.
Was this page helpful?