diff --git a/backend/internal/payments/offer.go b/backend/internal/payments/offer.go index 6fa7f32..ee48c50 100644 --- a/backend/internal/payments/offer.go +++ b/backend/internal/payments/offer.go @@ -117,9 +117,28 @@ func offerPrice(e catalogEntry, method string, cur Currency) string { return "—" } -// offerCell escapes an owner-entered title for a markdown table cell: a literal pipe would break the -// column layout, and a newline would break the row. +// offerCellReplacer neutralises every metacharacter of an admin-entered title so it renders as +// literal text in the public offer. The title is operator input (the /_gm catalog editor) that flows +// into a markdown table cell and then through marked into the /offer/ HTML, which is deliberately not +// sanitised — so escaping here is the trust boundary. It covers HTML (no tag or entity reaches the +// page), the markdown table pipe and the row newline, and the link brackets (a title must never +// become a "javascript:" link). marked passes the entities through unchanged, so the reader sees the +// exact title. NewReplacer scans once and never re-scans its own output, so "&" → "&" does not +// double-escape the entities the other rules emit. +var offerCellReplacer = strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", + "|", `\|`, + "[", `\[`, + "]", `\]`, + "\n", " ", +) + +// offerCell escapes an admin-entered title for safe, literal rendering in a markdown table cell of +// the public offer (see [offerCellReplacer]). func offerCell(s string) string { - s = strings.ReplaceAll(s, "\n", " ") - return strings.ReplaceAll(s, "|", "\\|") + return offerCellReplacer.Replace(s) } diff --git a/backend/internal/payments/offer_test.go b/backend/internal/payments/offer_test.go index f387ae1..0173723 100644 --- a/backend/internal/payments/offer_test.go +++ b/backend/internal/payments/offer_test.go @@ -60,6 +60,30 @@ func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) { } } +// TestProjectOfferPricingEscapesHTMLAndLinks checks an admin title carrying HTML or a markdown link +// is neutralised so it cannot inject markup into the public offer: the tag becomes entities and the +// link brackets are escaped (so no "javascript:" anchor forms). The raw metacharacters must not +// survive into the projected markdown. +func TestProjectOfferPricingEscapesHTMLAndLinks(t *testing.T) { + entries := []catalogEntry{{ + id: uuid.New(), + title: ` [x](javascript:alert(2)) & "q"`, + atoms: []atomQty{{atomType: "hints", quantity: 1}}, + prices: []priceRow{{method: "", currency: CurrencyChip, amount: 5}}, + }} + md := projectOfferPricing(entries) + for _, bad := range []string{"", "[x]", `& "q"`} { + if strings.Contains(md, bad) { + t.Errorf("unescaped %q survived into the projection:\n%s", bad, md) + } + } + for _, want := range []string{"<script>", `\[x\]`, "&", ""q""} { + if !strings.Contains(md, want) { + t.Errorf("want escaped %q in:\n%s", want, md) + } + } +} + // TestProjectOfferPricingEmpty checks an empty catalog projects to the empty string (no stray table // headers), so the offer's pricing marker is replaced with nothing. func TestProjectOfferPricingEmpty(t *testing.T) {