From 98bc6de8f57fda4bd3ccc1880ba22b9ce1d6582a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 18 Oct 2026 14:52:30 +0200 Subject: [PATCH] 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 --- README.md | 1 + docs/architecture/introduction.md | 2 +- docs/services/markdown.md | 50 +++++++ modules/breakwater/README.md | 57 ++++++++ modules/breakwater/breakwater.go | 110 +++++++++++++++ modules/breakwater/breakwater_test.go | 186 ++++++++++++++++++++++++++ modules/breakwater/example_test.go | 25 ++++ 7 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 docs/services/markdown.md create mode 100644 modules/breakwater/README.md create mode 100644 modules/breakwater/breakwater.go create mode 100644 modules/breakwater/breakwater_test.go create mode 100644 modules/breakwater/example_test.go diff --git a/README.md b/README.md index 6d844c40..d4f8f58b 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands | [boardwalk](modules/boardwalk/README.md) | HTTP handler that serves the embedded admin SPA build under a configurable path prefix. | The walkway along the beach front; it is the path admins take into the admin SPA. | | [bonfire](modules/bonfire/README.md) | Declarative console commands for the `summer` tool and application binaries, adapted to Cobra with typed input, prompts and styled output. | The evening fire where everyone gathers; the command line is where you sit down and fire off commands. | | [bouncer](modules/bouncer/README.md) | Authentication for SummerCMS: HS256 JWT minting, verification and refresh, request guards, a revoked-token blacklist and bcrypt password helpers. | Checks IDs at the beach club door; it authenticates requests and turns away the ones without a valid token. | +| [breakwater](modules/breakwater/README.md) | Markdown renderer and HTML sanitizer for user-submitted content: GitHub Flavored Markdown through goldmark, then a fixed bluemonday allow-list that adds nofollow and target=_blank to links. | A breakwater takes the force of the open sea; user Markdown and HTML break on the policy and only calm, allow-listed HTML reaches the shore. | | [cabana](modules/cabana/README.md) | Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filter and relation definitions at boot and serves them as a JSON admin API next to the embedded admin SPA. | The beach hut where the staff run the beach; it is the admin backend. | | [compass](modules/compass/README.md) | Layered YAML configuration with per-environment directories, `SUMMER_` environment overrides, embedded plugin defaults and dot-path access. | Points you the right way; configuration tells the application how to behave in each environment. | | [conga](modules/conga/README.md) | Background jobs on River over the shared Postgres pool: transactional dispatch, a `summer_jobs` progress record, in-process or dedicated workers, and a wall-clock scheduler. | A line of dancers moving one after another; jobs queue up and are worked through in order. | diff --git a/docs/architecture/introduction.md b/docs/architecture/introduction.md index 89d0c26f..f5c7ac54 100644 --- a/docs/architecture/introduction.md +++ b/docs/architecture/introduction.md @@ -30,7 +30,7 @@ Each framework module is one Go package under `modules/`, documented by its READ | Admin | [cabana](../../modules/cabana/README.md), [boardwalk](../../modules/boardwalk/README.md), [seaglass](../../modules/seaglass/README.md) | | Services | [cooler](../../modules/cooler/README.md), [phrasebook](../../modules/phrasebook/README.md), [postcard](../../modules/postcard/README.md), [conga](../../modules/conga/README.md), [lighthouse](../../modules/lighthouse/README.md), [flare](../../modules/flare/README.md), [sunscreen](../../modules/sunscreen/README.md) | | Console and tooling | [bonfire](../../modules/bonfire/README.md), [tide](../../modules/tide/README.md), [sunrise](../../modules/sunrise/README.md) | -| Utilities | [sieve](../../modules/sieve/README.md) | +| Utilities | [sieve](../../modules/sieve/README.md), [breakwater](../../modules/breakwater/README.md) | ## What runs where diff --git a/docs/services/markdown.md b/docs/services/markdown.md new file mode 100644 index 00000000..04b026b7 --- /dev/null +++ b/docs/services/markdown.md @@ -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 this.\n\n" +html := breakwater.Render(post) +fmt.Println(html) +// Output: +//

See the docs and this.

+``` + +```go src=modules/breakwater/example_test.go#ExamplePurify +// Content stored as HTML: the same allow-list, no Markdown pass. +stored := `

Hi there

call` +clean := breakwater.Purify(stored) +fmt.Println(clean) +// Output: +//

Hi there

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 bold more` renders to `

text bold more

`, with neither the tag nor an `<b>` 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. diff --git a/modules/breakwater/README.md b/modules/breakwater/README.md new file mode 100644 index 00000000..9052a098 --- /dev/null +++ b/modules/breakwater/README.md @@ -0,0 +1,57 @@ +# breakwater + +Markdown renderer and HTML sanitizer for user-submitted content: GitHub Flavored Markdown through goldmark, then a fixed bluemonday allow-list that adds nofollow and target=_blank to links. + +`import "git.golem15.com/golem15/summercms/modules/breakwater"` + +**Name:** A breakwater takes the force of the open sea; user Markdown and HTML break on the policy and only calm, allow-listed HTML reaches the shore. + +## Overview + +`breakwater` is the gate for content a site user submits and a plugin stores and serves: forum posts, comments, and any other Markdown or HTML that does not come from an administrator. `breakwater.Render` converts Markdown to HTML and `breakwater.Purify` sanitizes HTML that is already HTML; both return the policy's output, which the plugin stores on write and serves as is. + +The policy is the PHP `golem15/forum` HTMLPurifier config 1:1 (decision note `.planning/notes/2026-10-18-bluemonday-forum-policy.md`): a short element list, links with `http`, `https` or `mailto` only, `rel="nofollow"` on every link and `target="_blank"` on fully-qualified ones. The Markdown pass is [goldmark](https://github.com/yuin/goldmark) with GFM, in its default safe mode: raw HTML in the Markdown is stripped and a link with an unsafe destination is dropped before the policy runs, the analog of the PHP `html_input=strip` and `allow_unsafe_links=false`. + +**Not this module.** [seaglass](../seaglass/README.md) is the policy for rich text an administrator stores through cabana's `richeditor` field: bluemonday's UGC policy, which keeps images, relative and `tel:` links, and links as written without `nofollow`. `cabana.RenderMarkdown` is the admin Markdown field preview: CommonMark without GFM behind a regex gate that refuses unsafe output instead of removing it. Plugins must not use either for user content. + +## Features + +- Allowed elements: `p`, `br`, `strong`, `em`, `b`, `i`, `h1`–`h6`, `ul`, `ol`, `li`, `blockquote`, `pre`, `code`, `a` with `href`, `rel` and `target`, `table`, `thead`, `tbody`, `tr`, `th`, `td`, `hr`, `del`, `ins`, `sub`, `sup`. Everything else, including `img`, `figure`, `script`, `style`, `iframe`, `form`, `svg`, every `on*` attribute, `class` and `id`, is removed. +- 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. +- Every link gets `rel="nofollow"`; every fully-qualified `http` or `https` link also gets `target="_blank"` and `rel="noopener"`. A caller-supplied `rel` or `target` with any other value is dropped and rewritten. +- Raw HTML in Markdown is stripped, not escaped into visible text, and HTML comments are removed. +- Blank or whitespace-only input renders and purifies to `""`. +- Safe for concurrent use: the Markdown engine and the policy are built once and shared. + +## Usage + +```go +// A forum post: store the rendered HTML on write and serve it as is. +html := breakwater.Render(post.Content) + +// Content stored as HTML: the same allow-list, no Markdown pass. +clean := breakwater.Purify(training.ContentHTML) +``` + +`breakwater.Render("See [the docs](https://example.com/docs) and this.")` gives `

See the docs and this.

`. + +## API reference + +| Identifier | Description | +|------------|-------------| +| `breakwater.Render` | Converts user Markdown (GFM) to HTML with raw HTML stripped and unsafe links dropped, then returns it filtered through the policy; `""` for blank input. | +| `breakwater.Purify` | Returns HTML filtered through the same policy with no Markdown pass, for content stored as HTML; `""` for blank input. | + +## Dependencies + +- SummerCMS modules: none. +- Third-party: `github.com/microcosm-cc/bluemonday` v1.0.27 (the policy; decision note `.planning/notes/2026-10-18-bluemonday-forum-policy.md`), `github.com/yuin/goldmark` v1.8.6 with `extension.GFM` (the Markdown pass). +- Standard library: `bytes`, `regexp`, `strings`. + +## Testing + +```sh +go test ./modules/breakwater/... +``` + +The tests run in memory and need no external services. They cover the PHP `ForumMarkdownRenderTest` corpus (XSS cases, GFM table and fenced code), links and autolinks per scheme, blank input, the `Purify` allow-list and concurrent use. diff --git a/modules/breakwater/breakwater.go b/modules/breakwater/breakwater.go new file mode 100644 index 00000000..d5522d5e --- /dev/null +++ b/modules/breakwater/breakwater.go @@ -0,0 +1,110 @@ +// Package breakwater is the framework's gate for user-submitted content: a +// Markdown renderer and an HTML sanitizer with one fixed allow-list, for +// forum posts, comments and any other Markdown or HTML a site user submits +// that a plugin stores and serves. +// +// Render turns GitHub Flavored Markdown into HTML with raw HTML stripped and +// unsafe links dropped, then runs the policy; Purify runs the same policy on +// content that is already HTML. The policy is the forum policy of the +// 2026-10-18 decision note, 1:1 with the PHP golem15/forum HTMLPurifier +// config: a short element list, links with http, https or mailto only, +// rel="nofollow" on every link and target="_blank" on fully-qualified ones. +// +// It is not seaglass, the policy for rich text an administrator stores +// (bluemonday's UGC policy: images, relative and tel: links, links kept as +// written without nofollow), and it is not cabana.RenderMarkdown, the admin +// Markdown field preview (CommonMark without GFM behind a regex gate). +// Neither is a gate for user content; breakwater is. +package breakwater + +import ( + "bytes" + "regexp" + "strings" + + "github.com/microcosm-cc/bluemonday" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" +) + +// engine is the Markdown converter: CommonMark plus GFM (tables, +// strikethrough, autolinks, task lists). The HTML renderer keeps goldmark's +// default safe mode, so raw HTML in the input is written as an omitted-HTML +// comment and a link or autolink with a dangerous destination is written +// with an empty href (the PHP html_input=strip and allow_unsafe_links=false). +// A goldmark.Markdown is shared across goroutines, as cabana's markdownEngine +// is: parse state is per call. +var engine = goldmark.New(goldmark.WithExtensions(extension.GFM)) + +// policy is the forum policy. It is safe for concurrent use: bluemonday +// policies are read-only once built. It stays unexported because a +// *bluemonday.Policy is mutable. +var policy = newPolicy() + +var ( + // relTokens accepts one or more space-separated tokens the policy itself + // writes. Any other caller-supplied rel is dropped and rewritten. + relTokens = regexp.MustCompile(`^(nofollow|noopener|noreferrer)( (nofollow|noopener|noreferrer))*$`) + // targetBlank accepts the one target the policy writes. + targetBlank = regexp.MustCompile(`^_blank$`) +) + +// newPolicy builds the forum policy from an empty bluemonday policy, not +// from the UGC preset, so the allow-list is exactly the PHP one (per D-16): +// +// - HTML.Allowed: p, br, strong, em, b, i, h1-h6, ul, ol, li, blockquote, +// pre, code, a[href|rel|target], table, thead, tbody, tr, th, td, hr, +// del, ins, sub, sup. No img, no figure, nothing else. +// - URI.AllowedSchemes: http, https, mailto. No tel: and no relative URLs; +// a link whose href is refused loses its tag and keeps its text. +// - HTML.Nofollow: rel="nofollow" on every link. +// - HTML.TargetBlank: target="_blank" on every fully-qualified link, with +// the rel="noopener" bluemonday adds next to it. +// +// Comments stay disallowed (the bluemonday default), which is what removes +// goldmark's omitted-HTML comment from Render output. +func newPolicy() *bluemonday.Policy { + p := bluemonday.NewPolicy() + p.AllowElements( + "p", "br", "strong", "em", "b", "i", + "h1", "h2", "h3", "h4", "h5", "h6", + "ul", "ol", "li", "blockquote", + "pre", "code", "a", + "table", "thead", "tbody", "tr", "th", "td", + "hr", "del", "ins", "sub", "sup", + ) + p.AllowAttrs("href").OnElements("a") + p.AllowAttrs("rel").Matching(relTokens).OnElements("a") + p.AllowAttrs("target").Matching(targetBlank).OnElements("a") + p.AllowURLSchemes("http", "https", "mailto") + p.RequireNoFollowOnLinks(true) + p.AddTargetBlankToFullyQualifiedLinks(true) + return p +} + +// Render converts user Markdown to HTML and returns it filtered through the +// policy. The Markdown is GitHub Flavored (tables, strikethrough, autolinks); +// raw HTML in it is stripped, not escaped into visible text, and a link with +// a javascript:, data: or other unsafe destination is dropped with its text +// kept. Blank or whitespace-only markdown renders to "". +func Render(markdown string) string { + if strings.TrimSpace(markdown) == "" { + return "" + } + var buf bytes.Buffer + // Convert only reports its writer's error, and bytes.Buffer.Write never + // fails, so the error is unreachable; the buffer always goes through + // Purify, so no unsanitized output can be returned. + _ = engine.Convert([]byte(markdown), &buf) + return Purify(buf.String()) +} + +// Purify returns html filtered through the policy, with no Markdown pass: +// the same allow-list for content a plugin stores as HTML (the PHP purify() +// for trainings). Blank or whitespace-only html purifies to "". +func Purify(html string) string { + if strings.TrimSpace(html) == "" { + return "" + } + return policy.Sanitize(html) +} diff --git a/modules/breakwater/breakwater_test.go b/modules/breakwater/breakwater_test.go new file mode 100644 index 00000000..18b617a6 --- /dev/null +++ b/modules/breakwater/breakwater_test.go @@ -0,0 +1,186 @@ +package breakwater_test + +import ( + "strings" + "sync" + "testing" + + "git.golem15.com/golem15/summercms/modules/breakwater" +) + +// The corpora of the PHP golem15/forum ForumMarkdownRenderTest, verbatim. +const ( + xssCorpus = "\n\n[x](javascript:alert(1))\n\n\n\n" + gfmCorpus = "| A | B |\n|---|---|\n| 1 | 2 |\n\n```php\necho 'wave';\n```" +) + +func wantContains(t *testing.T, got string, subs ...string) { + t.Helper() + for _, s := range subs { + if !strings.Contains(got, s) { + t.Errorf("output lacks %q:\n%s", s, got) + } + } +} + +func wantNotContains(t *testing.T, got string, subs ...string) { + t.Helper() + for _, s := range subs { + if strings.Contains(got, s) { + t.Errorf("output contains %q:\n%s", s, got) + } + } +} + +func TestRenderXSS(t *testing.T) { + got := breakwater.Render(xssCorpus) + lower := strings.ToLower(got) + wantNotContains(t, lower, "alert(1)", nil}, + {"inline", "text bold more", []string{"text", "more"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := breakwater.Render(tc.in) + wantNotContains(t, got, "alert(1)", "onclick", "
") + wantContains(t, got, "

plain

") + wantNotContains(t, got, "