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
This commit is contained in:
Jakub Zych
2026-10-18 14:52:30 +02:00
parent 5ccf726e8a
commit 98bc6de8f5
7 changed files with 430 additions and 1 deletions

50
docs/services/markdown.md Normal file
View File

@@ -0,0 +1,50 @@
---
title: Markdown and HTML sanitizing
description: Render user Markdown to safe HTML and sanitize stored HTML with the breakwater allow-list; which gate serves admin fields instead.
section: services
order: 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](../../modules/breakwater/README.md) 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
```go src=modules/breakwater/example_test.go#ExampleRender
// 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>
```
```go src=modules/breakwater/example_test.go#ExamplePurify
// 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](../../modules/seaglass/README.md) for administrator rich text saved through cabana's `richeditor` field (see [Forms](../backend/forms.md)): 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.