Adapted from the repo's docs/adr/0007-server-side-markdown-rendering-and-sanitisation.md — Part of Internals: the platform's design set, published as content on the platform it describes.
- Status: Accepted
- Date: 2026-07-05
- Deciders: contract-owner, lead
- Tiers affected: contract, backend, frontend
- Related: ADR-0005 (Markdown originates on
disk), ADR-0006 (single read boundary),
ADR-0012 (amends/extends this ADR: one narrow
client-side rendering exception for the
mermaidfenced language),../master-blueprint.md§5.2, §6.1, §8
Context
Articles are Markdown on disk (ADR-0005) and must reach the reader as safe, styled HTML. The
question is where Markdown becomes HTML and where it is sanitised. The API is the single
coupling point (ADR-0006), and the same body feeds web pages, SSR, and feeds (RSS/Atom,
sitemap). Even owner-authored content must be sanitised — Markdown permits raw HTML, so an
unsanitised pipeline is an XSS vector. Deciding now fixes the shape of Article.body in the
contract before the front-end is built against it.
Decision
We will render Markdown to sanitised HTML server-side, through a single rendering +
sanitising pipeline (one MarkdownRenderer port: CommonMark → allowlist sanitiser →
HTML + table of contents), and deliver it as Article.body. The contract declares
bodyFormat: "html" so a future raw-Markdown variant is an additive change, not a break.
Clients render the trusted HTML directly; no client parses or sanitises Markdown.
Alternatives considered
- Ship raw Markdown, render client-side — smaller payload, flexible clients. Rejected: every client (web, feeds, any future consumer) must ship and maintain its own parser + sanitiser, producing inconsistent output and multiplying the XSS surface.
- Render server-side but do not sanitise — simpler pipeline. Rejected: Markdown allows embedded raw HTML/scripts, so unsanitised output is a direct XSS vulnerability the moment any client injects it.
Consequences
- Easier: one trusted, tested pipeline; identical output for pages and feeds; the
front-end injects
Article.bodywithout a Markdown dependency; the ToC is precomputed. - Harder / now forbidden: no per-controller or ad-hoc rendering; all HTML flows through the
one port. Adding a raw-Markdown response must stay additive under
bodyFormat. - Follow-up:
domain-engineerdefines theMarkdownRendererport;data-engineerimplements the CommonMark + allowlist-sanitiser pipeline and caches bycontent_hash(ADR-0005);contract-ownerdeclaresbodyFormat: "html"onArticle.
Compliance
- Single-pipeline scan: exactly one
MarkdownRendererimplementation; no controller, resource, or Domain action renders Markdown independently (import/review scan). - XSS test: a backend test feeds known XSS fixtures (e.g.
<script>,onerror=,javascript:URLs) and asserts the allowlist sanitiser strips them from the output. - Contract check:
Article.bodyis declared withbodyFormat: "html"incontract/openapi.yaml; a future raw variant must be an additive enum/field, verified by the breaking-change review atddev contract-lint.