fix(api): sanitize translated content_html on every write
A translated content_html sent through the Journal API (syncTranslations
formatted only when content_html was empty) or through
journal:import-posts (no check) was stored verbatim and served by the
anonymous GET /_journal/api/v1/posts and /posts/{slug}. A translation
keyed on the default locale also wrote the host content_html column
past FormatHTML.
classes.FormatTranslation now sets content_html for every translated
locale before it is stored: rendered from a non-blank content with
FormatHTML (host-column parity; a supplied content_html is replaced),
otherwise a supplied content_html is sanitized with the new
classes.SanitizeHTML, which FormatHTML reuses. Both write paths call it.
PHP parity note: Winter's PostApiController::syncTranslations called
Post::formatHtml only when content_html was empty and otherwise stored
the supplied content_html verbatim; ImportPosts::syncTranslations stored
it verbatim; the MLJournalMarkdown admin widget always derived it from
content via formatHtml. The Go port now follows the admin widget on
every path. Response shapes and status codes for valid input are
unchanged; only the stored content_html of unsafe or content-mismatched
translations differs.
This commit is contained in:
@@ -72,8 +72,54 @@ func FormatHTML(src string) (string, error) {
|
||||
if err := markdownEngine.Convert([]byte(strings.TrimSpace(src)), &buf); err != nil {
|
||||
return "", fmt.Errorf("journal: markdown: %w", err)
|
||||
}
|
||||
out, _ := sanitizeHTML(buf.String())
|
||||
return out, nil
|
||||
return SanitizeHTML(buf.String()), nil
|
||||
}
|
||||
|
||||
// SanitizeHTML filters doc, which is HTML and not markdown, through the
|
||||
// FormatHTML allow-list and strips what the policy does not allow. HTML the
|
||||
// policy keeps whole is returned byte for byte. It is the gate for
|
||||
// content_html supplied without markdown, such as a translation that carries
|
||||
// only content_html.
|
||||
func SanitizeHTML(doc string) string {
|
||||
out, _ := sanitizeHTML(doc)
|
||||
return out
|
||||
}
|
||||
|
||||
// FormatTranslation sets attrs["content_html"] for one locale's translated
|
||||
// post attributes, so a translated content_html is stored under the same
|
||||
// policy as the host column. When attrs["content"] is not blank, content_html
|
||||
// is rendered from it with FormatHTML and a supplied content_html is
|
||||
// replaced, as the host column is always derived from content. Otherwise a
|
||||
// supplied, non-null content_html is sanitized with SanitizeHTML; a value
|
||||
// that is not a string is sanitized as its text form. attrs without content
|
||||
// or content_html is left unchanged.
|
||||
func FormatTranslation(attrs map[string]any) error {
|
||||
if attrs == nil {
|
||||
return nil
|
||||
}
|
||||
if content := textOf(attrs["content"]); strings.TrimSpace(content) != "" {
|
||||
html, err := FormatHTML(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attrs["content_html"] = html
|
||||
return nil
|
||||
}
|
||||
if v, has := attrs["content_html"]; has && v != nil {
|
||||
attrs["content_html"] = SanitizeHTML(textOf(v))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func textOf(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
default:
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
|
||||
// CheckHTML gates HTML that is stored as is, such as category content. It
|
||||
|
||||
124
classes/format_translation_test.go
Normal file
124
classes/format_translation_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package classes
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeHTML(t *testing.T) {
|
||||
const clean = `<p>Zobacz <a href="https://example.test/a?b=1&c=2" class="ext">źródło</a>.</p>`
|
||||
if got := SanitizeHTML(clean); got != clean {
|
||||
t.Fatalf("clean HTML changed: %q", got)
|
||||
}
|
||||
if got, want := SanitizeHTML(`<p>a<img src=x onerror="alert(1)"></p>`), `<p>a<img src="x"></p>`; got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
// SanitizeHTML takes HTML, not markdown: markdown syntax stays text.
|
||||
if got := SanitizeHTML("**bold**"); got != "**bold**" {
|
||||
t.Fatalf("markdown was rendered: %q", got)
|
||||
}
|
||||
for _, tc := range unsafePayloads {
|
||||
if tc.src[0] != '<' {
|
||||
continue // markdown, not HTML
|
||||
}
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertNoActiveContent(t, SanitizeHTML(tc.src))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTranslation(t *testing.T) {
|
||||
const payload = `<p>Treść</p><img src="x" onerror="alert(1)"><a href="javascript:alert(1)">x</a>`
|
||||
|
||||
t.Run("content derives content_html", func(t *testing.T) {
|
||||
attrs := map[string]any{"title": "Witaj", "content": "Witaj **świecie**"}
|
||||
if err := FormatTranslation(attrs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := attrs["content_html"], "<p>Witaj <strong>świecie</strong></p>\n"; got != want {
|
||||
t.Fatalf("content_html = %#v, want %q", got, want)
|
||||
}
|
||||
if attrs["content"] != "Witaj **świecie**" || attrs["title"] != "Witaj" {
|
||||
t.Fatalf("other attributes changed: %#v", attrs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("content wins over a supplied content_html", func(t *testing.T) {
|
||||
attrs := map[string]any{"content": "Tekst", "content_html": payload}
|
||||
if err := FormatTranslation(attrs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := attrs["content_html"], "<p>Tekst</p>\n"; got != want {
|
||||
t.Fatalf("content_html = %#v, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("raw HTML in content is sanitized", func(t *testing.T) {
|
||||
attrs := map[string]any{"content": payload}
|
||||
if err := FormatTranslation(attrs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoActiveContent(t, attrs["content_html"].(string))
|
||||
})
|
||||
|
||||
t.Run("content_html alone is sanitized", func(t *testing.T) {
|
||||
for _, attrs := range []map[string]any{
|
||||
{"content_html": payload},
|
||||
{"content": "", "content_html": payload},
|
||||
{"content": " ", "content_html": payload},
|
||||
{"content": nil, "content_html": payload},
|
||||
} {
|
||||
if err := FormatTranslation(attrs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := attrs["content_html"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("content_html = %#v, want a string", attrs["content_html"])
|
||||
}
|
||||
assertNoActiveContent(t, got)
|
||||
if want := `<p>Treść</p><img src="x">x`; got != want {
|
||||
t.Fatalf("content_html = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clean content_html alone is kept byte for byte", func(t *testing.T) {
|
||||
const clean = "<p>Lion's Mane, \"superior herb\" & inne</p>"
|
||||
attrs := map[string]any{"content_html": clean}
|
||||
if err := FormatTranslation(attrs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attrs["content_html"] != clean {
|
||||
t.Fatalf("content_html = %#v, want %q", attrs["content_html"], clean)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-string content_html is sanitized as text", func(t *testing.T) {
|
||||
attrs := map[string]any{"content_html": []any{`<img src=x onerror="alert(1)">`}}
|
||||
if err := FormatTranslation(attrs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := attrs["content_html"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("content_html = %#v, want a string", attrs["content_html"])
|
||||
}
|
||||
assertNoActiveContent(t, got)
|
||||
})
|
||||
|
||||
t.Run("absent and null content_html stay as they are", func(t *testing.T) {
|
||||
attrs := map[string]any{"title": "Tytuł"}
|
||||
if err := FormatTranslation(attrs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, has := attrs["content_html"]; has {
|
||||
t.Fatalf("content_html added: %#v", attrs)
|
||||
}
|
||||
null := map[string]any{"content_html": nil}
|
||||
if err := FormatTranslation(null); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, has := null["content_html"]; !has || v != nil {
|
||||
t.Fatalf("null content_html changed: %#v", null)
|
||||
}
|
||||
if err := FormatTranslation(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user