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

View File

@@ -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. | | [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. | | [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. | | [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. | | [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. | | [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. | | [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. |

View File

@@ -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) | | 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) | | 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) | | 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 ## What runs where

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.

View File

@@ -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 <b onclick=\"x()\">this</b>.")` gives `<p>See <a href="https://example.com/docs" rel="nofollow noopener" target="_blank">the docs</a> and this.</p>`.
## 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.

View File

@@ -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)
}

View File

@@ -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 = "<script>alert(1)</script>\n\n[x](javascript:alert(1))\n\n<iframe src=x></iframe>\n\n<img src=x onerror=alert(1)>"
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, "<script", "javascript:", "<iframe", "onerror", "<img", "&lt;script", "<!--")
wantContains(t, got, "x")
}
func TestRenderStripsRawHTML(t *testing.T) {
cases := []struct {
name string
in string
keeps []string
}{
{"script", "<script>alert(1)</script>", nil},
{"inline", "text <b onclick=\"x()\">bold</b> 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", "<!--", "&lt;")
wantContains(t, got, tc.keeps...)
})
}
}
func TestRenderGFM(t *testing.T) {
got := breakwater.Render(gfmCorpus)
wantContains(t, got, "<table>", "<th>A</th>", "<td>1</td>", "<pre>", "<code")
wantNotContains(t, got, "class=")
got = breakwater.Render("~~gone~~ **kept**")
wantContains(t, got, "<del>gone</del>", "<strong>kept</strong>")
got = breakwater.Render("# Title\n\n> quote\n\n- a\n- b\n\n---")
wantContains(t, got, "<h1>", "<blockquote>", "<ul>", "<li>", "<hr")
}
func TestRenderLinks(t *testing.T) {
t.Run("absolute", func(t *testing.T) {
got := breakwater.Render("[site](https://example.com/a?b=1&c=2)")
wantContains(t, got, `href="https://example.com/a?b=1&amp;c=2"`, `target="_blank"`)
rel := relValue(t, got)
wantContains(t, rel, "nofollow", "noopener")
})
t.Run("autolink", func(t *testing.T) {
got := breakwater.Render("visit https://example.com/x now")
wantContains(t, got, `<a href="https://example.com/x"`, "nofollow", `target="_blank"`)
})
t.Run("mailto", func(t *testing.T) {
got := breakwater.Render("[mail](mailto:a@example.com)")
wantContains(t, got, `href="mailto:a@example.com"`, "nofollow")
})
dropped := []struct {
name, in, scheme, text string
}{
{"data", "[d](data:text/html;base64,AAAA)", "data:", "d"},
{"vbscript", "[v](vbscript:x)", "vbscript:", "v"},
{"tel", "[t](tel:+48123456)", "tel:", "t"},
}
for _, tc := range dropped {
t.Run(tc.name, func(t *testing.T) {
got := breakwater.Render(tc.in)
wantNotContains(t, got, "data:", "vbscript:", "tel:", "<a")
wantContains(t, got, tc.text)
})
}
t.Run("relative", func(t *testing.T) {
got := breakwater.Render("[rel](/threads/1)")
wantNotContains(t, got, "href")
wantContains(t, got, "rel")
})
}
// relValue returns the value of the first rel attribute in html.
func relValue(t *testing.T, html string) string {
t.Helper()
_, after, ok := strings.Cut(html, `rel="`)
if !ok {
t.Fatalf("no rel attribute in:\n%s", html)
}
val, _, ok := strings.Cut(after, `"`)
if !ok {
t.Fatalf("unterminated rel attribute in:\n%s", html)
}
return val
}
func TestBlankInput(t *testing.T) {
cases := []struct {
name string
got string
}{
{"render empty", breakwater.Render("")},
{"render whitespace", breakwater.Render(" \n\t ")},
{"purify empty", breakwater.Purify("")},
{"purify whitespace", breakwater.Purify(" \n ")},
}
for _, tc := range cases {
if tc.got != "" {
t.Errorf("%s: got %q, want \"\"", tc.name, tc.got)
}
}
}
func TestPurifyAllowList(t *testing.T) {
in := `<p onclick="x()">Hi <b>b</b> <i>i</i> <img src=x onerror=alert(1)><script>alert(1)</script></p>` +
`<figure><img src="a.jpg"></figure><h2 class="t" id="h">H</h2>` +
`<a href="https://e.com/p" target="_top" rel="opener">l</a><a href="tel:+48">t</a>` +
`<sub>s</sub><sup>p</sup><ins>i</ins><del>d</del><hr>` +
`<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>c</td></tr></tbody></table>` +
`<blockquote><pre><code>c &lt; d</code></pre></blockquote>` +
`<ul><li>u</li></ul><ol><li>o</li></ol><h6>6</h6><br>`
got := breakwater.Purify(in)
wantContains(t, got,
"<p>", "<b>b</b>", "<i>i</i>", "<h2>H</h2>", "<sub>s</sub>", "<sup>p</sup>", "<ins>i</ins>", "<del>d</del>",
"<hr", "<table>", "<thead>", "<th>h</th>", "<tbody>", "<td>c</td>", "<blockquote>", "<pre>",
"<code>c &lt; d</code>", "<ul>", "<ol>", "<li>", "<h6>6</h6>", "<br",
`href="https://e.com/p"`, `target="_blank"`,
)
rel := relValue(t, got)
wantContains(t, rel, "nofollow", "noopener")
lower := strings.ToLower(got)
wantNotContains(t, lower, "onclick", "onerror", "<img", "<script", "<figure", "class=", "id=",
`target="_top"`, `rel="opener"`, "tel:", "alert(")
// The text of the dropped tel link survives.
wantContains(t, got, "</a>t<sub>")
got = breakwater.Purify("<p>plain</p><!-- c --><style>p{}</style><form><input></form><svg onload=x></svg><iframe src=x></iframe>")
wantContains(t, got, "<p>plain</p>")
wantNotContains(t, got, "<!--", "<style", "<form", "<input", "<svg", "onload", "<iframe")
}
func TestConcurrentUse(t *testing.T) {
wantRender := breakwater.Render(gfmCorpus)
wantPurify := breakwater.Purify(wantRender)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 20; j++ {
if got := breakwater.Render(gfmCorpus); got != wantRender {
t.Errorf("concurrent Render differs:\n%s\nwant:\n%s", got, wantRender)
}
if got := breakwater.Purify(wantRender); got != wantPurify {
t.Errorf("concurrent Purify differs:\n%s\nwant:\n%s", got, wantPurify)
}
}
}()
}
wg.Wait()
}

View File

@@ -0,0 +1,25 @@
package breakwater_test
import (
"fmt"
"git.golem15.com/golem15/summercms/modules/breakwater"
)
func 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>
}
func 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
}