RFC-0012: Sanitize HTML bodies before they reach a page
- Status: Accepted (2026-10-13, approved by Halil with the defaults of the open questions as drafted; ADR-0026)
- Author: Halil (Madraka), drafted with Claude · Date: 2026-10-13
- Related: RFC-0001 (content bodies), RFC-0004 §2.3 (
asset:links in markdown only), RFC-0005 (roles), ADR-0011 (agent keys over REST), the 2026-10-13 security audit (PR #22, finding “HTML bodies are published as-is”),packages/astro-renderer/src/markdown.ts,csp.ts - Sources: WHATWG HTML parsing (https://html.spec.whatwg.org/multipage/parsing.html) · parse5, a spec-compliant HTML parser (https://github.com/inikulin/parse5) · OWASP XSS prevention (https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) · mutation XSS, Heiderich et al., CCS 2013 (https://cure53.de/fp170.pdf)
Summary
A content body or rich-text section can be markdown or html. Markdown is rendered as data: raw HTML is escaped and URLs are checked. HTML is published exactly as stored. This RFC sanitizes html bodies when they are rendered, with a fixed allowlist of elements, attributes and URL schemes, parsed the way browsers parse. Stored content is not changed.
Motivation
- Anyone who can write content can write HTML.
renderBodytrustshtmlbodies because “only humans can store them”. That is not true:- an agent key with
content:writestores them over REST (ADR-0011); - so do
cloudhal pushand a bundle import; - so do Contributors and Authors, the least trusted roles (RFC-0005).
- an agent key with
- What an HTML body can do today:
- On the Cloudflare site:
SITE_CSPblocks scripts, plugins and foreign frames. It does not block<meta http-equiv="refresh">(a redirect off the site), a<base>element, forms posting to the site itself, or overlays drawn with inline styles that imitate the site’s UI. - Static builds: CSP comes only from
_headers, so a host that ignores that file gets none. - Feeds: RSS and Atom carry the body to feed readers as stored.
- On the Cloudflare site:
- Who is affected: every visitor of a site whose content came from a less trusted writer, an agent included. Cloudhal’s promise is that agent changes are safe to approve. An approver reading a diff of raw HTML cannot be expected to spot a redirect.
- Why now: the audit found this, and agents writing HTML through REST will only become more common.
Proposal
Where
renderBody in @cloudhal/astro-renderer passes html bodies through sanitizeHtml(value) before returning them. Every page, preview, feed and static build goes through it. Markdown is unchanged: it already escapes raw HTML.
How
- Parse the body as an HTML fragment with parse5, inside a
<div>context, the way a browser parsesinnerHTML. A sanitizer that parses differently from the browser can be bypassed (mutation XSS). parse5 is a pure-JavaScript, spec-compliant implementation and runs in Workers. - Walk the tree and keep only the allowlist below.
- Elements not on the list are unwrapped: their children are kept.
- Elements in the drop set are removed with their content:
script,style,template,noscript,iframe,frame,object,embed,svg,math,form,input,button,select,textarea,meta,link,base,title. - Comments are removed.
- Serialize the result ourselves: element names from the allowlist, attribute values escaped (
& < > " '), and text escaped. Nothing from the input reaches the output unescaped except allowlisted names.
export function sanitizeHtml(html: string): string;/** What survives, exported for docs and tests. */export const HTML_ALLOWLIST: { elements: Record<string, readonly string[]>; global: readonly string[] };The allowlist (v1)
- Text and structure:
p br hr h1 h2 h3 h4 h5 h6 blockquote pre code kbd samp var strong b em i u s del ins mark small sub sup q cite abbr dfn time span div section aside figure figcaption details summary - Lists and tables:
ul ol li dl dt dd table caption thead tbody tfoot tr th td colgroup col - Links and media:
a img picture source audio video track - Global attributes:
title lang dir class, plusidwritten asuser-content-<id>so it cannot clobber the page’s own ids or globals. - Per element:
a:href, checked by the existingsafeHref.targetis kept only as_blank, and thenrel="noopener noreferrer"is forced.img:src, checked bysafeSrc;alt width height loading decoding.ol:start reversed type.th/td:colspan rowspan scope headers.time:datetime.details:open.audio/video:src poster controls preload muted loop playsinline;posteris checked like an image,srclike other media (below).source/track:src type kind srclang label;srcis checked like other media.
- URLs, the same rules markdown already applies (
markdown.ts):- links (
a href):safeHref, sohttp,https,mailto,telor relative; - images (
img src,video poster):safeSrc, sohttp,https, relative, or a rasterdata:image/(png|jpeg|gif|webp|avif);base64,…URL. Neverdata:image/svg+xml, nevermailto/tel; - other media (
audio/video/source/tracksrc):http,httpsor relative only, so nodata:URL. - A URL that fails its rule drops the attribute; an
imgwithout asrcis dropped.
- links (
- Never kept: event handlers (
on*),style,srcset(v1),formaction,xlink:*anddata-*attributes.
Invariants
- No element, attribute or URL outside the allowlist reaches a rendered page, a preview, a feed or a static build.
- Stored bodies are never modified. Sanitizing happens at render time, so a later, wider allowlist applies to old content without a migration.
- The output is well-formed HTML that the browser parses back to the same tree. The sanitizer’s own serializer is the only writer.
- Markdown rendering is unchanged.
- The sanitizer is deterministic and Worker-safe: no DOM, no
eval, no I/O.
Alternatives considered
| Option | Pros | Cons | Why not |
|---|---|---|---|
Rely on SITE_CSP alone |
Nothing to build | Misses meta refresh, base, UI redress; absent where _headers is ignored and in feeds |
The audit’s finding |
Only Editors and above (humans) may store html |
Small policy change | Breaks imports and existing content; Editors’ agents and pushes still bypass it; a role is not a review | Treats who, not what |
| Sanitize on write | Renderer stays small | Rewrites what people stored; old content stays unsanitized without a migration; imports need it too | Render time covers everything at once |
sanitize-html (htmlparser2) |
Popular, configurable | Its parser is not the browser’s, and it brings postcss | Parse differences are the classic bypass |
ultrahtml (already installed through Astro) |
No new dependency | Lenient parser, not spec-compliant, sanitizing is not its focus | Same parse-difference risk |
| DOMPurify | The reference sanitizer | Needs a DOM; jsdom does not run in Workers | Not Worker-safe |
| Convert HTML bodies to markdown | One renderer | Loses tables, figures and details; lossy for existing content | Too destructive |
Compatibility & migration
- Contracts: none.
format: "html"stays valid, and stored bodies are untouched. - Rendering: content that used dropped elements renders differently:
- inline
style; - embeds (
iframe), whichSITE_CSPalready blocks for foreign origins; - forms;
- SVG inline in the body.
The changeset lists what is dropped, and
cloudhal snapshotdiffs are unaffected.
- inline
- Bundle size: parse5 adds to the site Worker bundle. The growth is measured before merge and recorded in the PR, against the Workers script size limits.
- Implementation note (measured 2026-10-13,
pnpm smoke:packdry run with wrangler 4.148):- site Worker: 2,468 → 2,712 KiB, 510 → 566 KiB gzipped (+56 KiB);
- admin Worker, whose previews use the renderer: 1,287 → 1,343 KiB gzipped;
- both stay far under the Workers Free limit of 3 MB gzipped.
- Implementation note (measured 2026-10-13,
- Rollout: one release. Rollback: revert the renderer; stored content was never changed.
Security & policy impact
- Closes the audit’s finding for every host, independent of CSP.
- No new scopes or approval points. The
renderBodycomment is corrected: anhtmlbody may come from any writer. - The allowlist is code, reviewed like code. Widening it needs a change to this RFC’s list.
Open questions
- Inline
style: dropped in v1, because it enables UI redress. Should a small safe subset (text-align, colours) be kept instead? [default: drop all] - Embeds (YouTube, Vimeo): a later allowlist of
iframeorigins, each paired with a CSPframe-srcentry for that site. [default: not in v1] srcset: each candidate URL would need checking. [default: not in v1;asset:images already getsrcsetthrough markdown]- Approvals: should the approval diff warn when an HTML body loses content to the sanitizer, so a reviewer sees “3 elements dropped”? [default: follow-up]