From f60327c1ce6c2970b21bd2f53ea6268952dd7a44 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 16 Jul 2026 20:57:04 +0200 Subject: [PATCH] fix(payments): reject HTML-special chars in product titles (VK payment guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A product title containing & < > " ' silently breaks VK purchases: VK HTML-escapes the character in its order_status_change payment-callback echo (e.g. " -> ") but signs the notification over a different form, so the gateway's signature check no longer matches ("vk callback: bad signature") and the purchase never confirms — while get_item (whose request carries no title) still passes. validateProduct now rejects those characters at the admin catalog editor — the single write path for CreateProduct/UpdateProduct/SetProductActive — so a title can't reintroduce the break. Reject, not transform: the operator retypes a clean title (« » guillemets, not straight quotes), so the stored value stays identical everywhere (storefront, wallet, VK box). Docs: docs/PAYMENTS.md §7 + agent field notes; Go Doc on validateProduct. Test: validateProduct HTML-special-char cases. No wire/schema change. --- .claude/CLAUDE.md | 6 ++++++ backend/internal/payments/catalog_admin.go | 21 +++++++++++++------ .../internal/payments/catalog_admin_test.go | 6 ++++++ docs/PAYMENTS.md | 7 +++++++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 845e75d..b79e242 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -229,6 +229,12 @@ The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded unde - **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in `docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies. + - **VK payment title trap:** a product title with an HTML-special char (`& < > " '`) silently kills + VK purchases. VK HTML-escapes it in the `order_status_change` callback echo (`"`→`"`) but + signs a different form → the gateway logs `vk callback: bad signature` and rejects it (get_item + passes — its request carries no title). `validateProduct` now forbids those chars; keep titles on + « » guillemets. Diagnose a raw VK callback body by tcpdumping the plaintext caddy→gateway hop + (`tcp port 8081`, the h2c DATA frame is unencrypted); VK retries `order_status_change` for ~30 min. - **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in `deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with `--pg1-user=scrabble`. diff --git a/backend/internal/payments/catalog_admin.go b/backend/internal/payments/catalog_admin.go index ef4c850..c7abd99 100644 --- a/backend/internal/payments/catalog_admin.go +++ b/backend/internal/payments/catalog_admin.go @@ -56,16 +56,25 @@ func validCurrency(c Currency) bool { return false } -// validateProduct checks a product's composition and prices. When sellable is true (the product is -// or is becoming active) it also enforces the storefront shape: a pack (the chips atom ⇒ a money -// price per method and no CHIP price, chips-only) or a value (no chips ⇒ a single CHIP price and no -// money price). The tournament atom is never sellable (its economy is the tournament stage), so an -// active product may not carry it; an archived draft may (a template for later) and skips the shape -// check. +// validateProduct checks a product's composition and prices. The title must be non-empty and free of +// the HTML-special characters & < > " ' (they break VK's payment callback — see the inline note). +// When sellable is true (the product is or is becoming active) it also enforces the storefront shape: +// a pack (the chips atom ⇒ a money price per method and no CHIP price, chips-only) or a value (no +// chips ⇒ a single CHIP price and no money price). The tournament atom is never sellable (its economy +// is the tournament stage), so an active product may not carry it; an archived draft may (a template +// for later) and skips the shape check. func validateProduct(in ProductInput, sellable bool) error { if strings.TrimSpace(in.Title) == "" { return errors.New("product title is required") } + // Reject the HTML-special characters in a title. VK HTML-escapes them in its order_status_change + // payment echo (e.g. `"` -> `"`) but computes the notification signature over a different + // form, so our callback signature check no longer matches and the purchase cannot be confirmed. + // Titles never need them — use « » guillemets, not straight quotes. The rule holds for drafts too, + // since a draft is sold later. + if i := strings.IndexAny(in.Title, "&<>\"'"); i >= 0 { + return fmt.Errorf("product title must not contain %q — use « » guillemets, not straight quotes (VK payment callbacks break on it)", in.Title[i:i+1]) + } if len(in.Atoms) == 0 { return errors.New("product needs at least one atom") } diff --git a/backend/internal/payments/catalog_admin_test.go b/backend/internal/payments/catalog_admin_test.go index 7a60a80..540b232 100644 --- a/backend/internal/payments/catalog_admin_test.go +++ b/backend/internal/payments/catalog_admin_test.go @@ -34,6 +34,12 @@ func TestValidateProduct(t *testing.T) { {"money price without a method", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "", Currency: CurrencyRUB, Amount: 149}}}, true, true}, {"duplicate price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 1}, {Method: "direct", Currency: CurrencyRUB, Amount: 2}}}, true, true}, {"empty title", ProductInput{Title: " ", Atoms: value.Atoms, Prices: value.Prices}, true, true}, + {"title with a double quote", ProductInput{Title: `50 "фишек"`, Atoms: value.Atoms, Prices: value.Prices}, true, true}, + {"title with an ampersand", ProductInput{Title: "фишки & подсказки", Atoms: value.Atoms, Prices: value.Prices}, true, true}, + {"title with an angle bracket", ProductInput{Title: "50 <фишек>", Atoms: value.Atoms, Prices: value.Prices}, true, true}, + {"title with an apostrophe", ProductInput{Title: "player's pack", Atoms: value.Atoms, Prices: value.Prices}, true, true}, + {"title with a special char is refused even for a draft", ProductInput{Title: `x "y"`, Atoms: value.Atoms}, false, true}, + {"title with guillemets is allowed", ProductInput{Title: "50 «фишек»", Atoms: value.Atoms, Prices: value.Prices}, true, false}, {"no atoms", ProductInput{Title: "x", Prices: value.Prices}, true, true}, } for _, tt := range tests { diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index f2d9a10..ef49a0e 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -192,6 +192,13 @@ live in the database and are edited in the admin console, no release required. purchase stores a **snapshot** of what was sold (atom composition + price at the time) into an archive, so history/receipts/tax are independent of later catalog edits. +**Titles reject HTML-special characters.** The admin editor refuses a product title containing +`& < > " '` (`validateProduct`). VK HTML-escapes such characters in its `order_status_change` +payment-callback echo (e.g. `"` → `"`) but computes the notification signature over a different +form, so our callback signature check no longer matches and the purchase silently fails to confirm — +while the `get_item` phase (whose request carries no title) still passes. Use « » guillemets, not +straight quotes. + ## 8. Trusted platform signal The gate (§4) needs a **trusted, unforgeable** platform context on the server. The client is -- 2.52.0