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