- 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
111 lines
4.8 KiB
Go
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)
|
|
}
|