diff --git a/README.md b/README.md index 9fd0f7f..dee4119 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. 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. +- **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 every write path (admin save, `journal:import-posts`, Journal API writes). It renders markdown and passes raw HTML in `content` through the renderer (Winter parity), then sanitizes the result with a `bluemonday` allow-list: bluemonday's UGC policy plus `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` styles, goldmark's footnote roles, `dir="auto"` and two pasted-transcript `data-message-*` attributes. Like Winter's `Html::clean`, it strips what the policy does not allow instead of refusing the save: script, style, iframe, object, embed, form, base and meta elements, comments, event handler attributes, and URLs other than http, https, mailto and relative ones. HTML the policy keeps whole is stored byte for byte, so imported bodies survive a save. `content` itself is stored as submitted. 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`). @@ -177,7 +177,7 @@ func (Post) MorphName() string { return `Golem15\Journal\Models\Post | `models.Settings` | Singleton settings row; `TableName` is `golem15_journal_settings`. | | `models.All` | Models registered by this plugin. | | `updates.All` | gormigrate steps registered by this plugin. | -| `classes.CheckHTML` | The `FormatHTML` safety gate for HTML stored as is, such as category content. | +| `classes.CheckHTML` | Gate for HTML stored as is, such as category content: refuses HTML that the `FormatHTML` allow-list would strip or change, instead of stripping it. | ## CLI commands @@ -198,7 +198,7 @@ Both commands write JSON files (one per post slug), matching the PHP `journal:ex - SummerCMS modules: backpack, beachcomber, bonfire, cabana, lagoon/attach, pact, party, surf. - Compiled plugin: `golem15.translate`. -- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`, `gocloud.dev`. Test-only: `github.com/testcontainers/testcontainers-go`. +- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`, `github.com/microcosm-cc/bluemonday`, `golang.org/x/net/html`, `gocloud.dev`. Test-only: `github.com/testcontainers/testcontainers-go`. ## Testing diff --git a/classes/check_html_test.go b/classes/check_html_test.go index cc6f2fd..059c3b5 100644 --- a/classes/check_html_test.go +++ b/classes/check_html_test.go @@ -11,14 +11,29 @@ func TestCheckHTML(t *testing.T) { {"empty", "", true}, {"heading and link", `

Cordyceps

Plain link

`, true}, {"table", `
A
1
`, true}, + {"polish date label", `

Data: 12 maja 2024

`, true}, + {"metadata text", `

Some metadata: here

`, true}, + {"quotes, apostrophes and nbsp", `

Co to jest Lion's Mane? 

"superior herb" & inne

`, true}, + {"pasted chat transcript", `

x

`, true}, {"script", ``, false}, {"iframe", ``, false}, {"object", ``, false}, {"embed", ``, false}, {"event handler", ``, false}, + {"event handler without a space", `

`, false}, + {"slash-separated svg onload", "
\n\n
", false}, {"javascript url", ``, false}, + {"numeric entity colon", `x`, false}, + {"named entity colon", `x`, false}, + {"entity inside the scheme", `x`, false}, + {"tab inside the scheme", "x", false}, {"vbscript url mixed case", ``, false}, {"data url", ``, false}, + {"base", ``, false}, + {"meta refresh", ``, false}, + {"form", `
`, false}, + {"style element", ``, false}, + {"comment", `

a

`, 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) + } + }) + } } diff --git a/classes/format_html.go b/classes/format_html.go index f5ce7d4..513f583 100644 --- a/classes/format_html.go +++ b/classes/format_html.go @@ -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: