package classes import ( "strings" "testing" xhtml "golang.org/x/net/html" ) // 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", ""}, {"mixed-case script", ""}, {"script inside an HTML block", "
\n

Intro

\n\n
"}, {"script after markdown", "# Title\n\nText.\n\n"}, {"iframe", ``}, {"upper-case iframe", ``}, {"object", ``}, {"embed", ``}, {"event", ``}, {"svg onload", ``}, {"mixed-case event handler", `

Click

`}, {"event handler in an inline element", "Text with hover."}, {"event handler without a space", `

`}, {"slash-separated svg onload", "
\n\n
"}, {"markdown attribute event handler", "# Title {onclick=\"alert(1)\"}"}, {"javascript", "[x](javascript:alert(1))"}, {"mixed-case javascript href", `x`}, {"numeric entity colon", `x`}, {"named entity colon", `x`}, {"entity inside the scheme", `x`}, {"tab inside the scheme", "x"}, {"newline inside the scheme", "

x

"}, {"leading space and control characters", "

x

"}, {"mathml xlink href", "
\nx\n
"}, {"vbscript", "[x](vbscript:msgbox(1))"}, {"raw vbscript href", `x`}, {"data", "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)"}, {"raw data URL attribute", ``}, {"markdown image with a data URL", "![x](data:image/png;base64,iVBORw0KGgo=)"}, {"base", ``}, {"meta refresh", ``}, {"form", `
`}, {"style element", ""}, {"link stylesheet", ``}, {"html comment", "

a

"}, } // 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, "

") { t.Fatalf("safe heading: html=%s err=%v", html, err) } table, err := FormatHTML("| a | b |\n|:- | -:|\n| 1 | 2 |") if err != nil { t.Fatalf("table markdown: %v", err) } if !strings.Contains(table, "`, `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) } } attrs, err := FormatHTML("# Title {#intro .lead}") if err != nil || !strings.Contains(attrs, `

Title

`) { 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(`

Zdjęcie: a

`) if err != nil { t.Fatal(err) } if want := `

Zdjęcie: a

`; 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, src, want string }{ {"polish date label", "

Data: 12 maja 2024

", "

Data: 12 maja 2024

"}, {"markdown date label", "Data publikacji: 12 maja 2024", "

Data publikacji: 12 maja 2024

\n"}, {"metadata", "Some metadata: here", "

Some metadata: here

\n"}, {"big data in html", "

Big data: trends

", "

Big data: trends

"}, {"scheme names in prose", "

Pisze się javascript: i vbscript: w tekście.

", "

Pisze się javascript: i vbscript: w tekście.

"}, {"quotes and apostrophes", "

Lion's Mane, \"superior herb\" & inne

", "

Lion's Mane, \"superior herb\" & inne

"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { out, err := FormatHTML(tc.src) if err != nil { t.Fatalf("FormatHTML(%q): %v", tc.src, err) } if out != tc.want { t.Fatalf("got %q, want %q", out, tc.want) } }) } } // TestFormatHTMLKeepsRawHTML pins D-03: raw HTML in content passes through // 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 = "

Grzyby rosną w lesie.

\n

Druga część.

" out, err := FormatHTML(paragraphs) if err != nil { t.Fatalf("html paragraphs: %v", err) } if strings.TrimSpace(out) != paragraphs { t.Fatalf("html paragraphs changed:\n%s", out) } mixed, err := FormatHTML("# Tytuł\n\nTekst.\n\n
Autor
") if err != nil { t.Fatalf("markdown with a raw footer: %v", err) } if !strings.Contains(mixed, "

Tytuł

") || !strings.Contains(mixed, `
Autor
`) { t.Fatalf("raw footer was dropped:\n%s", mixed) } // Imported-style bodies: each round-trips unchanged after trimming. bodies := []struct { name string src string }{ {"paragraphs", "

Pierwszy akapit.

\n

Drugi akapit z kursywą.

\n

Trzeci.

"}, {"class attributes", `

Wstęp

`}, {"https link", `

Zobacz źródło.

`}, {"relative and mailto links", `

blog przypisy mail

`}, {"https image", `

Grzyby

`}, {"entity in an attribute", `

link Tom i Jerry

`}, {"table", "\n\n
AB
"}, {"headings", "

Co to jest Lion's Mane?

\n

Właściwości 

"}, {"pasted chat transcript", `

Tekst

`}, {"surrounding whitespace", "\n\n

Trimmed.

\n"}, } for _, tc := range bodies { t.Run(tc.name, func(t *testing.T) { out, err := FormatHTML(tc.src) if err != nil { t.Fatalf("FormatHTML(%q): %v", tc.src, err) } if strings.TrimSpace(out) != strings.TrimSpace(tc.src) { t.Fatalf("html changed:\n got %q\nwant %q", strings.TrimSpace(out), strings.TrimSpace(tc.src)) } if strings.Contains(out, "raw HTML omitted") { t.Fatalf("goldmark placeholder in output: %s", out) } }) } t.Run("markdown with a trailing raw footer and style", func(t *testing.T) { const footer = `` const style = "" src := "## Nagłówek\n\nTekst z **pogrubieniem** i [linkiem](https://example.test).\n\n" + footer + "\n\n" + style out, err := FormatHTML(src) if err != nil { t.Fatalf("markdown with footer and style: %v", err) } for _, want := range []string{ "

Nagłówek

", "pogrubieniem", `linkiem`, footer, } { 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