Files
summercms/modules/breakwater/breakwater.go
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

111 lines
4.8 KiB
Go

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