Files
summercms/docs/services/markdown.md
Jakub Zych 98bc6de8f5 feat(breakwater): Markdown renderer and HTML sanitizer with the forum policy (D-17)
- modules/breakwater exports Render(markdown) and Purify(html): goldmark GFM
  in its default safe mode (raw HTML stripped, unsafe links dropped), then a
  named bluemonday policy built from NewPolicy, not the UGC preset
- the policy is the PHP golem15/forum HTMLPurifier config 1:1: the fixed
  element list, a[href|rel|target], http/https/mailto only, rel=nofollow on
  every link, target=_blank plus noopener on fully-qualified links
- tests cover the PHP ForumMarkdownRenderTest corpus, links per scheme,
  blank input, the Purify allow-list and concurrent use; testable examples
  feed the docs page snippets
- module README, root module table row, introduction.md Utilities row and
  docs/services/markdown.md (which gate serves user content vs admin fields)
- go.mod and go.sum unchanged: bluemonday v1.0.27 and goldmark v1.8.6 were
  already pinned
2026-10-18 14:52:30 +02:00

3.6 KiB
Raw Blame History

title, description, section, order
title description section order
Markdown and HTML sanitizing Render user Markdown to safe HTML and sanitize stored HTML with the breakwater allow-list; which gate serves admin fields instead. services 165

Markdown and HTML sanitizing

A plugin that stores content a site user submits, such as forum posts, comments or HTML imported from another system, serves that content on a public page. breakwater is the one gate it goes through: breakwater.Render turns Markdown into HTML and breakwater.Purify sanitizes content that is already HTML, both with the same fixed allow-list. The plugin calls the gate on write, stores the result and serves it as is.

The two calls

// A forum post: store the rendered HTML on write and serve it as is.
post := "See [the docs](https://example.com/docs) and <b onclick=\"x()\">this</b>.\n\n<script>alert(1)</script>"
html := breakwater.Render(post)
fmt.Println(html)
// Output:
// <p>See <a href="https://example.com/docs" rel="nofollow noopener" target="_blank">the docs</a> and this.</p>
// Content stored as HTML: the same allow-list, no Markdown pass.
stored := `<p onclick="x()">Hi <b>there</b><img src="a.jpg"></p><a href="tel:+48">call</a>`
clean := breakwater.Purify(stored)
fmt.Println(clean)
// Output:
// <p>Hi <b>there</b></p>call

breakwater.Render is GitHub Flavored Markdown: tables, fenced code, strikethrough, links and autolinks render; breakwater.Purify is the policy alone, for content a plugin keeps as HTML. Neither returns an error: both return the policy's output, and "" for blank or whitespace-only input.

The policy

The allow-list is the PHP golem15/forum HTMLPurifier config, 1:1.

Rule Value
Allowed elements p, br, strong, em, b, i, h1–h6, ul, ol, li, blockquote, pre, code, a, table, thead, tbody, tr, th, td, hr, del, ins, sub, sup. Everything else is removed, including img, figure, script, style, iframe, form, svg and every on* attribute; class and id are dropped from the elements that stay.
a attributes href, rel and target. A caller-supplied rel or target with a value the policy would not write itself is dropped and rewritten.
URL schemes http, https and mailto. No tel: and no relative URLs; a link whose href is refused loses its tag and keeps its text.
Link rewriting Every link gets rel="nofollow"; every fully-qualified http or https link also gets target="_blank" and rel="noopener".

Raw HTML in Markdown is stripped, not escaped into visible text: text <b onclick="x()">bold</b> more renders to <p>text bold more</p>, with neither the tag nor an &lt;b&gt; in the output. HTML comments are removed. A Markdown link to a javascript:, data: or vbscript: destination is dropped before the policy runs, and its text stays.

Which gate

  • breakwater for user content: anything a site user submits that a plugin stores and serves, in Markdown or HTML.
  • seaglass for administrator rich text saved through cabana's richeditor field (see Forms): bluemonday's UGC policy, which keeps images, relative and tel: links, and links as written without nofollow. seaglass.Sanitize is not a gate for user content.
  • cabana.RenderMarkdown for the admin Markdown field preview only: CommonMark without GFM behind a regex gate that refuses unsafe output instead of removing it.