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:
@@ -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. |
|
||||||
|
|||||||
@@ -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
50
docs/services/markdown.md
Normal 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 `<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.
|
||||||
57
modules/breakwater/README.md
Normal file
57
modules/breakwater/README.md
Normal 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.
|
||||||
110
modules/breakwater/breakwater.go
Normal file
110
modules/breakwater/breakwater.go
Normal 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)
|
||||||
|
}
|
||||||
186
modules/breakwater/breakwater_test.go
Normal file
186
modules/breakwater/breakwater_test.go
Normal 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", "<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", "<!--", "<")
|
||||||
|
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&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 < 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 < 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()
|
||||||
|
}
|
||||||
25
modules/breakwater/example_test.go
Normal file
25
modules/breakwater/example_test.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user