Skip to content
bflo.sh

ADR-0007: Markdown is rendered to sanitised HTML server-side

ADR-0007 — Accepted 2026-07-05. Part of the platform's decision record.

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 mermaid fenced 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

  1. 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.
  2. 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.body without 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-engineer defines the MarkdownRenderer port; data-engineer implements the CommonMark + allowlist-sanitiser pipeline and caches by content_hash (ADR-0005); contract-owner declares bodyFormat: "html" on Article.

Compliance

  • Single-pipeline scan: exactly one MarkdownRenderer implementation; 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.body is declared with bodyFormat: "html" in contract/openapi.yaml; a future raw variant must be an additive enum/field, verified by the breaking-change review at ddev contract-lint.