From 528d2d84510bdeb7bc282b9a41d54280d9d2dcdc Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 9 Oct 2026 20:58:49 +0200 Subject: [PATCH] fix(classes): keep raw HTML in content_html behind the safety gate - FormatHTML renders with goldmark html.WithUnsafe(), as Winter's Markdown::parse passes raw HTML through, so an admin save, an import or an API write no longer turns imported HTML into raw HTML omitted - rejectUnsafe still runs after the render and refuses script, iframe, object and embed tags, event handlers and javascript, vbscript and data URLs; API response shapes are unchanged - TestFormatHTMLKeepsRawHTML covers HTML paragraphs and a raw footer --- README.md | 2 +- classes/format_html.go | 29 ++++++++++++++++++----------- classes/format_html_test.go | 22 ++++++++++++++++++++++ 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 88c8626..9fd0f7f 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later ## Features -- **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`/`mlmarkdown`. `classes.FormatHTML` regenerates `content_html` on admin save. +- **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`/`mlmarkdown`. `classes.FormatHTML` regenerates `content_html` on admin save. It passes raw HTML in `content` through unchanged (Winter parity), so imported HTML bodies survive a save, and its gate still refuses script, iframe, object and embed tags, event handlers and javascript, vbscript and data URLs on every write path (admin save, `journal:import-posts`, Journal API writes). The API response shapes are unchanged. - **Categories and tags.** `models.Category` is translatable (name, description, slug). It also stores `content`, an HTML host column that is not translatable, edited in the admin as a textarea. Fillable is name, slug, code, description, content and parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`. - **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense. - **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. Writes under the same prefix require a cabana backend JWT (`aud=backend`). diff --git a/classes/format_html.go b/classes/format_html.go index e6588db..f5ce7d4 100644 --- a/classes/format_html.go +++ b/classes/format_html.go @@ -9,12 +9,17 @@ import ( "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer/html" ) var ( markdownEngine = goldmark.New( 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. + goldmark.WithRendererOptions(html.WithUnsafe()), ) markdownUnsafeTag = regexp.MustCompile(`(?i)<(?:script|iframe|object|embed)\b`) @@ -23,35 +28,37 @@ var ( ) // FormatHTML converts journal post markdown to stored HTML using goldmark -// footnote, table, and attribute extensions, then the same rejectUnsafe gate -// as cabana.RenderMarkdown. It does not call cabana.RenderMarkdown. +// 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. 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) } - html := buf.String() - if err := rejectUnsafe(html); err != nil { + out := buf.String() + if err := rejectUnsafe(out); err != nil { return "", err } - return html, nil + 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. -func CheckHTML(html string) error { - return rejectUnsafe(html) +func CheckHTML(doc string) error { + return rejectUnsafe(doc) } -func rejectUnsafe(html string) error { - if markdownUnsafeTag.MatchString(html) { +func rejectUnsafe(doc string) error { + if markdownUnsafeTag.MatchString(doc) { return fmt.Errorf("journal: rendered HTML contains raw unsafe tags") } - if markdownEventHandler.MatchString(html) { + if markdownEventHandler.MatchString(doc) { return fmt.Errorf("journal: rendered HTML contains event handlers") } - if markdownDangerousURL.MatchString(html) { + if markdownDangerousURL.MatchString(doc) { return fmt.Errorf("journal: rendered HTML contains a dangerous URL scheme") } return nil diff --git a/classes/format_html_test.go b/classes/format_html_test.go index 2df11da..d7ec99e 100644 --- a/classes/format_html_test.go +++ b/classes/format_html_test.go @@ -56,6 +56,28 @@ func TestFormatHTMLRejectsUnsafeHTML(t *testing.T) { } } +// 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. +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") + if err != nil { + t.Fatalf("markdown with a raw footer: %v", err) + } + if !strings.Contains(mixed, "

Tytuł

") || !strings.Contains(mixed, ``) { + t.Fatalf("raw footer was dropped:\n%s", mixed) + } +} + // TestJournalEndToEnd keeps `go test ./... -run TestJournalEndToEnd` // from reporting [no tests to run] in this package. func TestJournalEndToEnd(t *testing.T) {}