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) {