From 07eae2d97181bd15d403259ea37cbbdff25c671d Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 26 Jun 2026 23:40:59 +0200 Subject: [PATCH] fix: stop marking JSON attributes as trusted HTML --- templates/funcs.go | 5 ++- templates/funcs_test.go | 8 ++++- templates/renderer_test.go | 67 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/templates/funcs.go b/templates/funcs.go index f77f3714..84dadc48 100644 --- a/templates/funcs.go +++ b/templates/funcs.go @@ -3,7 +3,6 @@ package templates import ( "encoding/json" "fmt" - "html/template" "mal/internal/domain" "net/url" "os" @@ -34,12 +33,12 @@ func dict(values ...any) (map[string]any, error) { return out, nil } -func jsonAttr(v any) (template.HTMLAttr, error) { +func jsonAttr(v any) (string, error) { b, err := json.Marshal(v) if err != nil { return "", fmt.Errorf("json marshal: %w", err) } - return template.HTMLAttr(b), nil + return string(b), nil } func genresParams(genres []int) string { diff --git a/templates/funcs_test.go b/templates/funcs_test.go index 8b1dfde1..cdcfaac4 100644 --- a/templates/funcs_test.go +++ b/templates/funcs_test.go @@ -47,7 +47,13 @@ func TestJSONAttr(t *testing.T) { if err != nil { t.Fatalf("jsonAttr error: %v", err) } - want := template.HTMLAttr(`{"a":1}`) + if _, ok := any(got).(template.HTMLAttr); ok { + t.Fatal("jsonAttr returned trusted HTML attribute content") + } + if _, ok := any(got).(string); !ok { + t.Fatalf("jsonAttr returned %T, want string", got) + } + want := `{"a":1}` if got != want { t.Errorf("expected %q, got %q", want, got) } diff --git a/templates/renderer_test.go b/templates/renderer_test.go index e18b32e8..726d86f3 100644 --- a/templates/renderer_test.go +++ b/templates/renderer_test.go @@ -2,9 +2,14 @@ package templates import ( "bytes" + "encoding/json" + "mal/integrations/jikan" + "mal/internal/domain" "net/http/httptest" "strings" "testing" + + "github.com/PuerkitoBio/goquery" ) func TestProvideRendererParsesTemplates(t *testing.T) { @@ -102,6 +107,68 @@ func TestRenderWithNonStringFragment(t *testing.T) { } } +func TestWatchTemplateEscapesJSONDataAttributes(t *testing.T) { + r, err := ProvideRenderer() + if err != nil { + t.Fatal(err) + } + + label := `English ' data-injected='yes' &"` + var buf bytes.Buffer + err = r.ExecuteFragment(&buf, "watch.gohtml", "content", domain.WatchPageData{ + Anime: domain.Anime{Anime: jikan.Anime{MalID: 123, Title: "Example Anime"}}, + CurrentEpID: "1", + Episodes: []domain.CanonicalEpisode{ + {Number: 1, Title: "Episode 1", HasSub: true}, + }, + WatchData: domain.WatchData{ + MalID: 123, + Title: "Example Anime", + CurrentEpisode: "1", + ModeSources: map[string]domain.ModeSource{ + "sub": { + Token: "stream-token", + Subtitles: []domain.SubtitleItem{ + {Lang: label, Token: "subtitle-token"}, + }, + }, + }, + AvailableModes: []string{"sub"}, + }, + }) + if err != nil { + t.Fatalf("ExecuteFragment error: %v", err) + } + + doc, err := goquery.NewDocumentFromReader(strings.NewReader(buf.String())) + if err != nil { + t.Fatalf("parse rendered html: %v", err) + } + player := doc.Find("[data-video-player]") + if got := player.Length(); got != 1 { + t.Fatalf("data-video-player count = %d, want 1", got) + } + if injected, ok := player.Attr("data-injected"); ok { + t.Fatalf("json data created injected attribute %q in html:\n%s", injected, buf.String()) + } + if got := player.Find("b").Length(); got != 0 { + t.Fatalf("json data created %d nested b tags in html:\n%s", got, buf.String()) + } + + raw, ok := player.Attr("data-mode-sources") + if !ok { + t.Fatalf("missing data-mode-sources in html:\n%s", buf.String()) + } + var sources map[string]domain.ModeSource + if err := json.Unmarshal([]byte(raw), &sources); err != nil { + t.Fatalf("data-mode-sources is not recoverable json: %v\nraw: %s", err, raw) + } + got := sources["sub"].Subtitles[0].Lang + if got != label { + t.Fatalf("subtitle label mismatch\nwant: %q\ngot: %q", label, got) + } +} + func TestExecuteFragmentValid(t *testing.T) { r, err := ProvideRenderer() if err != nil {