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.
240 lines
8.2 KiB
Go
240 lines
8.2 KiB
Go
package journal_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-journal-plugin/console"
|
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
|
)
|
|
|
|
// translatedXSS is a translated content_html carrying an event handler and a
|
|
// javascript URL. Stored verbatim it would reach anonymous readers through
|
|
// the public posts API.
|
|
const translatedXSS = `<p>Treść</p><img src="x" onerror="alert(1)"><a href="javascript:alert(1)">x</a>`
|
|
|
|
// translatedXSSClean is translatedXSS after the FormatHTML allow-list.
|
|
const translatedXSSClean = `<p>Treść</p><img src="x">x`
|
|
|
|
// assertNoXSS fails when html still holds the translatedXSS payload.
|
|
func assertNoXSS(t *testing.T, where, html string) {
|
|
t.Helper()
|
|
lower := strings.ToLower(html)
|
|
for _, bad := range []string{"onerror", "javascript:", "<script"} {
|
|
if strings.Contains(lower, bad) {
|
|
t.Fatalf("%s holds %q: %q", where, bad, html)
|
|
}
|
|
}
|
|
}
|
|
|
|
// publicTranslations GETs a post anonymously and returns its translations
|
|
// and host content_html.
|
|
func publicTranslations(t *testing.T, env *adminEnv, slug string) (map[string]map[string]any, string) {
|
|
t.Helper()
|
|
rec := env.call(t, http.MethodGet, "/_journal/api/v1/posts/"+slug, "", nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("anonymous show %s = %d: %s", slug, rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Data struct {
|
|
ContentHTML string `json:"content_html"`
|
|
Translations json.RawMessage `json:"translations"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("show json: %s err=%v", rec.Body.String(), err)
|
|
}
|
|
tr := map[string]map[string]any{}
|
|
if string(body.Data.Translations) != "[]" {
|
|
if err := json.Unmarshal(body.Data.Translations, &tr); err != nil {
|
|
t.Fatalf("translations json: %s err=%v", body.Data.Translations, err)
|
|
}
|
|
}
|
|
return tr, body.Data.ContentHTML
|
|
}
|
|
|
|
// TestJournalAPITranslatedContentHTML pins W-1 on the API write path: a
|
|
// translated content_html is stored sanitized, a translation with only
|
|
// content gets a derived content_html, and valid writes keep their status.
|
|
func TestJournalAPITranslatedContentHTML(t *testing.T) {
|
|
env := newPostsEnv(t)
|
|
tok := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessPublish)
|
|
past := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)
|
|
|
|
create := func(slug string, translations map[string]any) {
|
|
t.Helper()
|
|
rec := env.call(t, http.MethodPost, "/_journal/api/v1/posts", tok, map[string]any{
|
|
"title": "Post " + slug,
|
|
"slug": slug,
|
|
"content": "Host **body**",
|
|
"published": true,
|
|
"published_at": past,
|
|
"translations": translations,
|
|
})
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("create %s = %d, want 201: %s", slug, rec.Code, rec.Body.String())
|
|
}
|
|
assertNoXSS(t, "create response", rec.Body.String())
|
|
}
|
|
|
|
t.Run("content_html only is sanitized", func(t *testing.T) {
|
|
create("tr-html-only", map[string]any{
|
|
"pl": map[string]any{"title": "Wpis", "content_html": translatedXSS},
|
|
})
|
|
tr, host := publicTranslations(t, env, "tr-html-only")
|
|
got, _ := tr["pl"]["content_html"].(string)
|
|
if got != translatedXSSClean {
|
|
t.Fatalf("pl content_html = %q, want %q", got, translatedXSSClean)
|
|
}
|
|
if host != "<p>Host <strong>body</strong></p>\n" {
|
|
t.Fatalf("host content_html = %q", host)
|
|
}
|
|
})
|
|
|
|
t.Run("content only derives content_html", func(t *testing.T) {
|
|
create("tr-content-only", map[string]any{
|
|
"pl": map[string]any{"title": "Wpis", "content": "Witaj **świecie**"},
|
|
})
|
|
tr, _ := publicTranslations(t, env, "tr-content-only")
|
|
if got, want := tr["pl"]["content_html"], "<p>Witaj <strong>świecie</strong></p>\n"; got != want {
|
|
t.Fatalf("pl content_html = %#v, want %q", got, want)
|
|
}
|
|
if got := tr["pl"]["content"]; got != "Witaj **świecie**" {
|
|
t.Fatalf("pl content = %#v, stored as submitted expected", got)
|
|
}
|
|
})
|
|
|
|
t.Run("content wins over a supplied content_html", func(t *testing.T) {
|
|
create("tr-both", map[string]any{
|
|
"pl": map[string]any{"content": "Tekst", "content_html": translatedXSS},
|
|
})
|
|
tr, _ := publicTranslations(t, env, "tr-both")
|
|
if got, want := tr["pl"]["content_html"], "<p>Tekst</p>\n"; got != want {
|
|
t.Fatalf("pl content_html = %#v, want %q", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("default-locale translation cannot bypass the host column", func(t *testing.T) {
|
|
create("tr-default-locale", map[string]any{
|
|
"en": map[string]any{"content_html": translatedXSS},
|
|
})
|
|
_, host := publicTranslations(t, env, "tr-default-locale")
|
|
if host != translatedXSSClean {
|
|
t.Fatalf("host content_html = %q, want %q", host, translatedXSSClean)
|
|
}
|
|
})
|
|
|
|
t.Run("update sanitizes translated content_html", func(t *testing.T) {
|
|
var post models.Post
|
|
if err := env.gdb.Where("slug = ?", "tr-content-only").Take(&post).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := env.call(t, http.MethodPut, fmt.Sprintf("/_journal/api/v1/posts/%d", post.ID), tok, map[string]any{
|
|
"translations": map[string]any{
|
|
"pl": map[string]any{"content": "", "content_html": translatedXSS},
|
|
},
|
|
})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("update = %d, want 200: %s", rec.Code, rec.Body.String())
|
|
}
|
|
assertNoXSS(t, "update response", rec.Body.String())
|
|
tr, _ := publicTranslations(t, env, "tr-content-only")
|
|
if got := tr["pl"]["content_html"]; got != translatedXSSClean {
|
|
t.Fatalf("pl content_html after update = %#v, want %q", got, translatedXSSClean)
|
|
}
|
|
})
|
|
|
|
list := env.call(t, http.MethodGet, "/_journal/api/v1/posts", "", nil)
|
|
if list.Code != http.StatusOK {
|
|
t.Fatalf("anonymous list = %d: %s", list.Code, list.Body.String())
|
|
}
|
|
assertNoXSS(t, "anonymous list", list.Body.String())
|
|
}
|
|
|
|
// TestJournalImportTranslatedContentHTML pins W-1 on journal:import-posts:
|
|
// a translated content_html in the import file is stored sanitized, and a
|
|
// translation with only content gets a derived content_html.
|
|
func TestJournalImportTranslatedContentHTML(t *testing.T) {
|
|
env := newPostsEnv(t)
|
|
dir := t.TempDir()
|
|
published := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)
|
|
files := []console.PostFile{
|
|
{
|
|
Slug: "import-html-only",
|
|
Title: "Import HTML",
|
|
Content: "Host",
|
|
Published: true,
|
|
PublishedAt: &published,
|
|
Translations: map[string]map[string]any{
|
|
"pl": {"title": "Import HTML", "content_html": translatedXSS},
|
|
},
|
|
},
|
|
{
|
|
Slug: "import-content-only",
|
|
Title: "Import content",
|
|
Content: "Host",
|
|
Published: true,
|
|
PublishedAt: &published,
|
|
Translations: map[string]map[string]any{
|
|
"pl": {"title": "Import treści", "content": "Witaj **świecie**"},
|
|
},
|
|
},
|
|
}
|
|
for _, f := range files {
|
|
raw, err := json.Marshal(f)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, f.Slug+".json"), raw, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
stats, err := console.ImportFromDir(context.Background(), env.gdb, dir, false, false, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("import: %v", err)
|
|
}
|
|
if stats.Created != 2 || stats.Failed != 0 {
|
|
t.Fatalf("import stats = %+v, want 2 created", stats)
|
|
}
|
|
|
|
stored := func(slug string) map[string]any {
|
|
t.Helper()
|
|
var post models.Post
|
|
if err := env.gdb.Where("slug = ?", slug).Take(&post).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var raw string
|
|
if err := env.gdb.Table("golem15_translate_attributes").
|
|
Select("attribute_data").
|
|
Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(post.ID), post.MorphName(), "pl").
|
|
Scan(&raw).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var data map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
|
t.Fatalf("attribute_data %q: %v", raw, err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
if got := stored("import-html-only")["content_html"]; got != translatedXSSClean {
|
|
t.Fatalf("imported pl content_html = %#v, want %q", got, translatedXSSClean)
|
|
}
|
|
if got, want := stored("import-content-only")["content_html"], "<p>Witaj <strong>świecie</strong></p>\n"; got != want {
|
|
t.Fatalf("imported pl content_html = %#v, want %q", got, want)
|
|
}
|
|
|
|
tr, _ := publicTranslations(t, env, "import-html-only")
|
|
if got := tr["pl"]["content_html"]; got != translatedXSSClean {
|
|
t.Fatalf("public pl content_html = %#v, want %q", got, translatedXSSClean)
|
|
}
|
|
}
|