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 = `

Treść

x` // translatedXSSClean is translatedXSS after the FormatHTML allow-list. const translatedXSSClean = `

Treść

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:", "Witaj świecie

\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"], "

Tekst

\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"], "

Witaj świecie

\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) } }