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
This commit is contained in:
2026-10-09 20:58:49 +02:00
parent 3d533d49f0
commit 528d2d8451
3 changed files with 41 additions and 12 deletions

View File

@@ -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`).

View File

@@ -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

View File

@@ -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 = "<p>Grzyby rosną w lesie.</p>\n<p>Druga <b>część</b>.</p>"
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<footer class=\"author\">Autor</footer>")
if err != nil {
t.Fatalf("markdown with a raw footer: %v", err)
}
if !strings.Contains(mixed, "<h1>Tytuł</h1>") || !strings.Contains(mixed, `<footer class="author">Autor</footer>`) {
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) {}