fix(classes): sanitize content_html with bluemonday instead of regex checks
- FormatHTML sanitizes the goldmark output with a bluemonday UGC policy extended only with what goldmark and the imported posts and categories use; unsafe markup is stripped, as Winter's Html::clean strips it - HTML the policy keeps whole is stored byte for byte (token comparison) - CheckHTML keeps its signature and 422 contract, refusing HTML the policy would change - the three regexes are gone: the CR-01 bypasses are neutralized and "Data:" or "metadata:" text is no longer refused (WR-01)
This commit is contained in:
@@ -11,14 +11,29 @@ func TestCheckHTML(t *testing.T) {
|
||||
{"empty", "", true},
|
||||
{"heading and link", `<h2>Cordyceps</h2><p>Plain <a href="https://example.test/a">link</a></p>`, true},
|
||||
{"table", `<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>`, true},
|
||||
{"polish date label", `<p>Data: 12 maja 2024</p>`, true},
|
||||
{"metadata text", `<p>Some metadata: here</p>`, true},
|
||||
{"quotes, apostrophes and nbsp", `<h2>Co to jest Lion's Mane? </h2><p>"superior herb" & inne</p>`, true},
|
||||
{"pasted chat transcript", `<div data-message-author-role="assistant" data-message-id="cf51192a-db36-4816-8d29-b0606b53d289" dir="auto"><p>x</p></div>`, true},
|
||||
{"script", `<script>alert(1)</script>`, false},
|
||||
{"iframe", `<iframe src="https://example.test/"></iframe>`, false},
|
||||
{"object", `<object data=x>`, false},
|
||||
{"embed", `<embed src=x>`, false},
|
||||
{"event handler", `<img src=x onerror="alert(1)">`, false},
|
||||
{"event handler without a space", `<p><img src="x"onerror="alert(1)"></p>`, false},
|
||||
{"slash-separated svg onload", "<div>\n<svg/onload=alert(1)>\n</div>", false},
|
||||
{"javascript url", `<a href="javascript:alert(1)">`, false},
|
||||
{"numeric entity colon", `<a href="javascript:alert(1)">x</a>`, false},
|
||||
{"named entity colon", `<a href="javascript:alert(1)">x</a>`, false},
|
||||
{"entity inside the scheme", `<a href="javascript:alert(1)">x</a>`, false},
|
||||
{"tab inside the scheme", "<a href=\"java\tscript:alert(1)\">x</a>", false},
|
||||
{"vbscript url mixed case", `<a href="VBScript:x">`, false},
|
||||
{"data url", `<img src="data:image/png;base64,AAAA">`, false},
|
||||
{"base", `<base href="https://evil.test/">`, false},
|
||||
{"meta refresh", `<meta http-equiv="refresh" content="0;url=https://evil.test">`, false},
|
||||
{"form", `<form action="https://evil.test"><input type=password></form>`, false},
|
||||
{"style element", `<style>p { color: red }</style>`, false},
|
||||
{"comment", `<p>a</p><!-- hidden -->`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -31,4 +46,16 @@ func TestCheckHTML(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Every stored-XSS payload FormatHTML strips is refused by CheckHTML.
|
||||
for _, tc := range unsafePayloads {
|
||||
if tc.src[0] != '<' {
|
||||
continue // markdown, not HTML
|
||||
}
|
||||
t.Run("payload "+tc.name, func(t *testing.T) {
|
||||
if err := CheckHTML(tc.src); err == nil {
|
||||
t.Fatalf("CheckHTML(%q) = nil, want an error", tc.src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,19 @@ package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
xhtml "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -17,49 +22,105 @@ var (
|
||||
goldmark.WithExtensions(extension.Footnote, extension.Table),
|
||||
goldmark.WithParserOptions(parser.WithAttribute()),
|
||||
// Winter's Markdown::parse passes raw HTML through, so imported HTML
|
||||
// bodies round-trip unchanged (D-03, PHP parity); rejectUnsafe gates
|
||||
// the rendered result on every write path.
|
||||
// bodies round-trip (D-03, PHP parity); htmlPolicy then sanitizes the
|
||||
// rendered result on every write path.
|
||||
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||
)
|
||||
|
||||
markdownUnsafeTag = regexp.MustCompile(`(?i)<(?:script|iframe|object|embed)\b`)
|
||||
markdownEventHandler = regexp.MustCompile(`(?i)\son[a-z]+\s*=`)
|
||||
markdownDangerousURL = regexp.MustCompile(`(?i)(?:javascript|vbscript|data):`)
|
||||
// htmlPolicy is the allow-list for stored journal HTML.
|
||||
htmlPolicy = newHTMLPolicy()
|
||||
)
|
||||
|
||||
// newHTMLPolicy extends bluemonday's UGC policy only with what goldmark
|
||||
// renders and what the imported posts and categories use.
|
||||
func newHTMLPolicy() *bluemonday.Policy {
|
||||
p := bluemonday.UGCPolicy()
|
||||
// Winter neither adds nor requires rel="nofollow", so links keep their
|
||||
// stored form.
|
||||
p.RequireNoFollowOnLinks(false)
|
||||
// Imported author footers: <footer class=... itemscope itemtype=...>.
|
||||
p.AllowElements("footer")
|
||||
p.AllowAttrs("class").Matching(bluemonday.SpaceSeparatedTokens).Globally()
|
||||
p.AllowAttrs("itemscope").Matching(regexp.MustCompile(`^(|itemscope)$`)).Globally()
|
||||
p.AllowAttrs("itemtype").Matching(regexp.MustCompile(`^https?://schema\.org/[A-Za-z]+$`)).Globally()
|
||||
p.AllowAttrs("itemprop").Matching(bluemonday.SpaceSeparatedTokens).Globally()
|
||||
// Inline styles in imported footers and goldmark's table cell alignment.
|
||||
// bluemonday validates each property value.
|
||||
p.AllowStyles("color", "font-size", "font-weight", "text-align").Globally()
|
||||
// goldmark's footnote markup.
|
||||
p.AllowAttrs("role").Matching(regexp.MustCompile(`^doc-(noteref|endnotes|backlink)$`)).OnElements("a", "div")
|
||||
// Pasted chat transcripts in imported category bodies: dir="auto" (the
|
||||
// UGC policy allows only ltr and rtl) and two data attributes.
|
||||
p.AllowAttrs("dir").Matching(regexp.MustCompile(`(?i)^(ltr|rtl|auto)$`)).Globally()
|
||||
p.AllowAttrs("data-message-author-role", "data-message-id").
|
||||
Matching(regexp.MustCompile(`^[A-Za-z0-9_-]+$`)).OnElements("div")
|
||||
return p
|
||||
}
|
||||
|
||||
// FormatHTML converts journal post markdown to stored HTML using goldmark
|
||||
// footnote, table, and attribute extensions. Raw HTML in the source passes
|
||||
// through unchanged; the result then goes through the same rejectUnsafe gate
|
||||
// as cabana.RenderMarkdown. It does not call cabana.RenderMarkdown, whose
|
||||
// admin preview stays without unsafe HTML.
|
||||
// through the renderer, as it does in Winter's Markdown::parse. The result is
|
||||
// then sanitized with a bluemonday allow-list that strips, as Winter's
|
||||
// Html::clean does, what it does not allow: script, style, iframe, object,
|
||||
// embed, form, base and meta elements, event handler attributes, and URLs
|
||||
// other than http, https, mailto and relative ones. HTML that the policy
|
||||
// keeps whole is returned byte for byte, so imported bodies round-trip. It
|
||||
// does not call cabana.RenderMarkdown, whose admin preview stays without
|
||||
// unsafe HTML.
|
||||
func FormatHTML(src string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := markdownEngine.Convert([]byte(strings.TrimSpace(src)), &buf); err != nil {
|
||||
return "", fmt.Errorf("journal: markdown: %w", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if err := rejectUnsafe(out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, _ := sanitizeHTML(buf.String())
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CheckHTML applies the FormatHTML safety gate to HTML that is stored as is,
|
||||
// such as category content. It refuses raw script, iframe, object and embed
|
||||
// tags, event handler attributes and javascript, vbscript or data URL schemes.
|
||||
// CheckHTML gates HTML that is stored as is, such as category content. It
|
||||
// uses the FormatHTML allow-list but refuses instead of stripping: doc is
|
||||
// refused when the policy would remove or change any element, attribute or
|
||||
// text in it.
|
||||
func CheckHTML(doc string) error {
|
||||
return rejectUnsafe(doc)
|
||||
}
|
||||
|
||||
func rejectUnsafe(doc string) error {
|
||||
if markdownUnsafeTag.MatchString(doc) {
|
||||
return fmt.Errorf("journal: rendered HTML contains raw unsafe tags")
|
||||
}
|
||||
if markdownEventHandler.MatchString(doc) {
|
||||
return fmt.Errorf("journal: rendered HTML contains event handlers")
|
||||
}
|
||||
if markdownDangerousURL.MatchString(doc) {
|
||||
return fmt.Errorf("journal: rendered HTML contains a dangerous URL scheme")
|
||||
if _, changed := sanitizeHTML(doc); changed {
|
||||
return errors.New("journal: HTML contains elements, attributes or URLs that are not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeHTML returns doc unchanged when htmlPolicy keeps all of it, and the
|
||||
// policy's output with changed set otherwise. Keeping all of it means doc and
|
||||
// the policy output tokenize to the same HTML tokens, so an output that only
|
||||
// re-escapes text or attribute values is not a change.
|
||||
func sanitizeHTML(doc string) (string, bool) {
|
||||
clean := htmlPolicy.Sanitize(doc)
|
||||
if clean == doc || sameTokens(doc, clean) {
|
||||
return doc, false
|
||||
}
|
||||
return clean, true
|
||||
}
|
||||
|
||||
// sameTokens reports whether a and b tokenize to the same sequence of HTML
|
||||
// tokens: type, tag name or decoded text, and the set of decoded attributes.
|
||||
func sameTokens(a, b string) bool {
|
||||
ta, oka := htmlTokens(a)
|
||||
tb, okb := htmlTokens(b)
|
||||
return oka && okb && slices.Equal(ta, tb)
|
||||
}
|
||||
|
||||
func htmlTokens(doc string) ([]string, bool) {
|
||||
var out []string
|
||||
z := xhtml.NewTokenizer(strings.NewReader(doc))
|
||||
for {
|
||||
tt := z.Next()
|
||||
if tt == xhtml.ErrorToken {
|
||||
return out, errors.Is(z.Err(), io.EOF)
|
||||
}
|
||||
t := z.Token()
|
||||
attrs := make([]string, 0, len(t.Attr))
|
||||
for _, a := range t.Attr {
|
||||
attrs = append(attrs, a.Namespace+":"+a.Key+"="+a.Val)
|
||||
}
|
||||
slices.Sort(attrs)
|
||||
out = append(out, fmt.Sprintf("%d|%s|%q", tt, t.Data, attrs))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,76 +3,192 @@ package classes
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
xhtml "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// TestFormatHTMLRejectsUnsafeHTML substitutes JOURNAL-003/004 Twig XSS
|
||||
// templates (Phase 16). Script, iframe, event handlers, and javascript /
|
||||
// vbscript / data schemes must fail the reject gate. Footnotes and tables
|
||||
// from goldmark extensions still pass.
|
||||
func TestFormatHTMLRejectsUnsafeHTML(t *testing.T) {
|
||||
// unsafePayloads are the stored-XSS inputs FormatHTML and CheckHTML must
|
||||
// neutralize: the JOURNAL-003/004 Twig XSS templates (Phase 16) and the
|
||||
// regex-gate bypasses from the Phase 16.1 code review (CR-01).
|
||||
var unsafePayloads = []struct {
|
||||
name string
|
||||
src string
|
||||
}{
|
||||
{"script", "<script>alert(1)</script>"},
|
||||
{"mixed-case script", "<ScRiPt>alert(1)</sCrIpT>"},
|
||||
{"script inside an HTML block", "<div>\n<p>Intro</p>\n<script>alert(1)</script>\n</div>"},
|
||||
{"script after markdown", "# Title\n\nText.\n\n<script src=\"https://evil.test/x.js\"></script>"},
|
||||
{"iframe", `<iframe src="https://evil.test"></iframe>`},
|
||||
{"upper-case iframe", `<IFRAME SRC="https://evil.test"></IFRAME>`},
|
||||
{"object", `<object data="https://evil.test/x.swf"></object>`},
|
||||
{"embed", `<embed src="https://evil.test/x.swf">`},
|
||||
{"event", `<img src=x onerror="alert(1)">`},
|
||||
{"svg onload", `<svg onload="alert(1)"><circle r="1"/></svg>`},
|
||||
{"mixed-case event handler", `<p OnClick="alert(1)">Click</p>`},
|
||||
{"event handler in an inline element", "Text with <span onmouseover=\"alert(1)\">hover</span>."},
|
||||
{"event handler without a space", `<p><img src="x"onerror="alert(1)"></p>`},
|
||||
{"slash-separated svg onload", "<div>\n<svg/onload=alert(1)>\n</div>"},
|
||||
{"markdown attribute event handler", "# Title {onclick=\"alert(1)\"}"},
|
||||
{"javascript", "[x](javascript:alert(1))"},
|
||||
{"mixed-case javascript href", `<a href="JaVaScRiPt:alert(1)">x</a>`},
|
||||
{"numeric entity colon", `<a href="javascript:alert(1)">x</a>`},
|
||||
{"named entity colon", `<a href="javascript:alert(1)">x</a>`},
|
||||
{"entity inside the scheme", `<a href="javascript:alert(1)">x</a>`},
|
||||
{"tab inside the scheme", "<a href=\"java\tscript:alert(1)\">x</a>"},
|
||||
{"newline inside the scheme", "<p><a href=\"java\nscript:alert(1)\">x</a></p>"},
|
||||
{"leading space and control characters", "<p><a href=\" \x01javascript:alert(1)\">x</a></p>"},
|
||||
{"mathml xlink href", "<div>\n<math><maction actiontype=statusline xlink:href=\"javascript:alert(1)\">x</maction></math>\n</div>"},
|
||||
{"vbscript", "[x](vbscript:msgbox(1))"},
|
||||
{"raw vbscript href", `<a href="VBScript:msgbox(1)">x</a>`},
|
||||
{"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)"},
|
||||
{"raw data URL attribute", `<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=">`},
|
||||
{"markdown image with a data URL", ""},
|
||||
{"base", `<base href="https://evil.test/">`},
|
||||
{"meta refresh", `<meta http-equiv="refresh" content="0;url=https://evil.test">`},
|
||||
{"form", `<form action="https://evil.test"><input type=password name=p><button formaction="javascript:alert(1)">go</button></form>`},
|
||||
{"style element", "<style>body { background: url(https://evil.test/x) }</style>"},
|
||||
{"link stylesheet", `<link rel="stylesheet" href="https://evil.test/x.css">`},
|
||||
{"html comment", "<p>a</p><!-- <script>alert(1)</script> -->"},
|
||||
}
|
||||
|
||||
// forbiddenElements must never appear in sanitized journal HTML.
|
||||
var forbiddenElements = map[string]bool{
|
||||
"script": true, "iframe": true, "object": true, "embed": true, "base": true,
|
||||
"meta": true, "form": true, "input": true, "button": true, "style": true,
|
||||
"link": true, "svg": true, "math": true, "maction": true, "frame": true,
|
||||
"frameset": true, "applet": true, "textarea": true, "select": true,
|
||||
}
|
||||
|
||||
// assertNoActiveContent tokenizes doc and fails on forbidden elements, event
|
||||
// handler attributes, any style other than the allowed properties, and
|
||||
// javascript, vbscript or data URLs after entity decoding and after removing
|
||||
// ASCII whitespace and control characters, as a browser's URL parser does.
|
||||
func assertNoActiveContent(t *testing.T, doc string) {
|
||||
t.Helper()
|
||||
z := xhtml.NewTokenizer(strings.NewReader(doc))
|
||||
for {
|
||||
tt := z.Next()
|
||||
if tt == xhtml.ErrorToken {
|
||||
return
|
||||
}
|
||||
if tt == xhtml.CommentToken {
|
||||
t.Fatalf("comment survived: %q", doc)
|
||||
}
|
||||
if tt != xhtml.StartTagToken && tt != xhtml.SelfClosingTagToken && tt != xhtml.EndTagToken {
|
||||
continue
|
||||
}
|
||||
tok := z.Token()
|
||||
if forbiddenElements[tok.Data] {
|
||||
t.Fatalf("element <%s> survived: %q", tok.Data, doc)
|
||||
}
|
||||
for _, a := range tok.Attr {
|
||||
key := strings.ToLower(a.Key)
|
||||
if strings.HasPrefix(key, "on") {
|
||||
t.Fatalf("event handler %s survived: %q", a.Key, doc)
|
||||
}
|
||||
if key == "action" || key == "formaction" || key == "srcdoc" || key == "http-equiv" {
|
||||
t.Fatalf("attribute %s survived: %q", a.Key, doc)
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(a.Val) {
|
||||
if r > 0x20 {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
v := b.String()
|
||||
for _, scheme := range []string{"javascript:", "vbscript:", "data:"} {
|
||||
if strings.Contains(v, scheme) {
|
||||
t.Fatalf("%s URL survived in %s: %q", scheme, a.Key, doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatHTMLSanitizesUnsafeHTML pins CR-01: every payload is rendered
|
||||
// without an error and with all active content stripped, as Winter's
|
||||
// Html::clean strips rather than refuses. Footnotes and tables from goldmark
|
||||
// extensions still render.
|
||||
func TestFormatHTMLSanitizesUnsafeHTML(t *testing.T) {
|
||||
html, err := FormatHTML("# Hello")
|
||||
if err != nil || !strings.Contains(html, "<h1>") {
|
||||
t.Fatalf("safe heading: html=%s err=%v", html, err)
|
||||
}
|
||||
|
||||
table, err := FormatHTML("| a | b |\n| - | - |\n| 1 | 2 |")
|
||||
table, err := FormatHTML("| a | b |\n|:- | -:|\n| 1 | 2 |")
|
||||
if err != nil {
|
||||
t.Fatalf("table markdown: %v", err)
|
||||
}
|
||||
if !strings.Contains(table, "<table") {
|
||||
t.Fatalf("table did not render: %s", table)
|
||||
if !strings.Contains(table, "<table") || !strings.Contains(table, "text-align: right") {
|
||||
t.Fatalf("table or its alignment did not render: %s", table)
|
||||
}
|
||||
|
||||
fn, err := FormatHTML("See footnote.[^1]\n\n[^1]: note")
|
||||
if err != nil {
|
||||
t.Fatalf("footnote markdown: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(fn), "footnote") && !strings.Contains(fn, "<sup") {
|
||||
t.Fatalf("footnote did not render: %s", fn)
|
||||
for _, want := range []string{`<sup id="fnref:1">`, `href="#fn:1"`, `role="doc-noteref"`, `role="doc-endnotes"`, `role="doc-backlink"`} {
|
||||
if !strings.Contains(fn, want) {
|
||||
t.Fatalf("footnote lacks %s: %s", want, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// With unsafe rendering the gate is the only defence, so every case
|
||||
// must be refused with an error, not merely rendered harmless.
|
||||
attrs, err := FormatHTML("# Title {#intro .lead}")
|
||||
if err != nil || !strings.Contains(attrs, `<h1 id="intro" class="lead">Title</h1>`) {
|
||||
t.Fatalf("markdown attributes: html=%s err=%v", attrs, err)
|
||||
}
|
||||
|
||||
for _, tc := range unsafePayloads {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := FormatHTML(tc.src)
|
||||
if err != nil {
|
||||
t.Fatalf("FormatHTML(%q) = %v, want sanitized output", tc.src, err)
|
||||
}
|
||||
assertNoActiveContent(t, out)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("safe parts of a payload survive", func(t *testing.T) {
|
||||
out, err := FormatHTML(`<p>Zdjęcie: <img src="https://cdn.example.test/a.jpg" alt="a"onerror="alert(1)"></p>`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := `<p>Zdjęcie: <img src="https://cdn.example.test/a.jpg" alt="a"></p>`; out != want {
|
||||
t.Fatalf("got %q, want %q", out, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormatHTMLKeepsSchemeLikeText pins WR-01: text that merely contains
|
||||
// "data:", "javascript:" or "vbscript:" is not a URL and passes unchanged.
|
||||
func TestFormatHTMLKeepsSchemeLikeText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
src string
|
||||
name, src, want string
|
||||
}{
|
||||
{"script", "<script>alert(1)</script>"},
|
||||
{"mixed-case script", "<ScRiPt>alert(1)</sCrIpT>"},
|
||||
{"script inside an HTML block", "<div>\n<p>Intro</p>\n<script>alert(1)</script>\n</div>"},
|
||||
{"script after markdown", "# Title\n\nText.\n\n<script src=\"https://evil.test/x.js\"></script>"},
|
||||
{"iframe", `<iframe src="https://evil.test"></iframe>`},
|
||||
{"upper-case iframe", `<IFRAME SRC="https://evil.test"></IFRAME>`},
|
||||
{"object", `<object data="https://evil.test/x.swf"></object>`},
|
||||
{"embed", `<embed src="https://evil.test/x.swf">`},
|
||||
{"event", `<img src=x onerror="alert(1)">`},
|
||||
{"svg onload", `<svg onload="alert(1)"><circle r="1"/></svg>`},
|
||||
{"mixed-case event handler", `<p OnClick="alert(1)">Click</p>`},
|
||||
{"event handler in an inline element", "Text with <span onmouseover=\"alert(1)\">hover</span>."},
|
||||
{"javascript", "[x](javascript:alert(1))"},
|
||||
{"mixed-case javascript href", `<a href="JaVaScRiPt:alert(1)">x</a>`},
|
||||
{"vbscript", "[x](vbscript:msgbox(1))"},
|
||||
{"raw vbscript href", `<a href="VBScript:msgbox(1)">x</a>`},
|
||||
{"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)"},
|
||||
{"raw data URL attribute", `<img src="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=">`},
|
||||
{"markdown image with a data URL", ""},
|
||||
{"polish date label", "<p>Data: 12 maja 2024</p>", "<p>Data: 12 maja 2024</p>"},
|
||||
{"markdown date label", "Data publikacji: 12 maja 2024", "<p>Data publikacji: 12 maja 2024</p>\n"},
|
||||
{"metadata", "Some metadata: here", "<p>Some metadata: here</p>\n"},
|
||||
{"big data in html", "<p>Big data: trends</p>", "<p>Big data: trends</p>"},
|
||||
{"scheme names in prose", "<p>Pisze się javascript: i vbscript: w tekście.</p>", "<p>Pisze się javascript: i vbscript: w tekście.</p>"},
|
||||
{"quotes and apostrophes", "<p>Lion's Mane, \"superior herb\" & inne</p>", "<p>Lion's Mane, \"superior herb\" & inne</p>"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := FormatHTML(tc.src)
|
||||
if err == nil {
|
||||
t.Fatalf("FormatHTML accepted unsafe input %q: %s", tc.src, out)
|
||||
if err != nil {
|
||||
t.Fatalf("FormatHTML(%q): %v", tc.src, err)
|
||||
}
|
||||
if out != "" {
|
||||
t.Fatalf("FormatHTML returned HTML with its error: %s", out)
|
||||
if out != tc.want {
|
||||
t.Fatalf("got %q, want %q", out, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatHTMLKeepsRawHTML pins D-03: raw HTML in content passes through
|
||||
// unchanged, as Winter's Markdown::parse does, so an imported HTML body
|
||||
// survives an admin save, an import or an API write.
|
||||
// the renderer, as Winter's Markdown::parse does, and HTML the policy allows
|
||||
// is stored byte for byte, so an imported HTML body survives an admin save,
|
||||
// an import or an API write.
|
||||
func TestFormatHTMLKeepsRawHTML(t *testing.T) {
|
||||
const paragraphs = "<p>Grzyby rosną w lesie.</p>\n<p>Druga <b>część</b>.</p>"
|
||||
out, err := FormatHTML(paragraphs)
|
||||
@@ -98,10 +214,13 @@ func TestFormatHTMLKeepsRawHTML(t *testing.T) {
|
||||
}{
|
||||
{"paragraphs", "<p>Pierwszy akapit.</p>\n<p>Drugi akapit z <em>kursywą</em>.</p>\n<p>Trzeci.</p>"},
|
||||
{"class attributes", `<div class="post-body"><p class="lead">Wstęp</p></div>`},
|
||||
{"https link", `<p>Zobacz <a href="https://example.test/grzyby?a=1" class="ext" target="_blank" rel="noopener">źródło</a>.</p>`},
|
||||
{"https link", `<p>Zobacz <a href="https://example.test/grzyby?a=1" class="ext" title="Źródło">źródło</a>.</p>`},
|
||||
{"relative and mailto links", `<p><a href="/blog/grzyby">blog</a> <a href="#przypisy">przypisy</a> <a href="mailto:kontakt@example.test">mail</a></p>`},
|
||||
{"https image", `<p><img src="https://cdn.example.test/img/fungi.jpg" alt="Grzyby" width="640" height="480"></p>`},
|
||||
{"entity in an attribute", `<p><a href="https://example.test/?a=1&b=2" title="Tom & Jerry "quoted"">link</a></p>`},
|
||||
{"entity in an attribute", `<p><a href="https://example.test/?a=1&b=2" title="Tom i Jerry">link</a> <img src="https://cdn.example.test/a.jpg?w=1&h=2" alt="Tom i Jerry"></p>`},
|
||||
{"table", "<table>\n<tr><td>A</td><td>B</td></tr>\n</table>"},
|
||||
{"headings", "<h2>Co to jest Lion's Mane?</h2>\n<h3>Właściwości </h3>"},
|
||||
{"pasted chat transcript", `<div data-message-author-role="assistant" data-message-id="a13366a9-f96b-4d6f-aaa5-0e433176381a" dir="auto"><p>Tekst</p></div>`},
|
||||
{"surrounding whitespace", "\n\n <p>Trimmed.</p> \n"},
|
||||
}
|
||||
for _, tc := range bodies {
|
||||
@@ -132,16 +251,53 @@ func TestFormatHTMLKeepsRawHTML(t *testing.T) {
|
||||
"<strong>pogrubieniem</strong>",
|
||||
`<a href="https://example.test">linkiem</a>`,
|
||||
footer,
|
||||
style,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output lacks %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// The style element is stripped with its content, as Html::clean
|
||||
// strips <style>.
|
||||
if strings.Contains(out, "<style") || strings.Contains(out, ".source {") {
|
||||
t.Fatalf("style element survived:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "raw HTML omitted") {
|
||||
t.Fatalf("goldmark placeholder in output: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("imported author footer with microdata and inline styles", func(t *testing.T) {
|
||||
src := "Tekst.\n\n<footer class=\"author-footer\" itemscope itemtype=\"https://schema.org/Person\">\n" +
|
||||
" <div class=\"author-info\">\n" +
|
||||
" <img src=\"https://cdn.example.test/autor.png\" alt=\"Autor\" class=\"author-photo\" itemprop=\"image\">\n" +
|
||||
" <div style=\"font-size: 1.5em; font-weight: bold;\" itemprop=\"name\">Jan Kowalski</div>\n" +
|
||||
" <span style=\"color: #138c74;\" itemprop=\"jobTitle\">Ekspert</span>\n" +
|
||||
" </div>\n</footer>"
|
||||
out, err := FormatHTML(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`<footer class="author-footer" itemscope="" itemtype="https://schema.org/Person">`,
|
||||
`<img src="https://cdn.example.test/autor.png" alt="Autor" class="author-photo" itemprop="image">`,
|
||||
`<div style="font-size: 1.5em; font-weight: bold" itemprop="name">Jan Kowalski</div>`,
|
||||
`<span style="color: #138c74" itemprop="jobTitle">Ekspert</span>`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output lacks %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disallowed styles and attributes are stripped", func(t *testing.T) {
|
||||
out, err := FormatHTML(`<p style="position: fixed; color: red" target="_blank" itemtype="https://evil.test/x">x</p>`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := `<p style="color: red">x</p>`; out != want {
|
||||
t.Fatalf("got %q, want %q", out, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestJournalEndToEnd keeps `go test ./... -run TestJournalEndToEnd`
|
||||
|
||||
Reference in New Issue
Block a user