Merge pull request 'fix(payments): reject HTML-special chars in product titles (VK payment guard)' (#279) from feature/vk-title-guard into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 20s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 11s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m18s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped

This commit was merged in pull request #279.
This commit is contained in:
2026-07-16 19:17:45 +00:00
4 changed files with 34 additions and 6 deletions
+6
View File
@@ -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 (E0E9). 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 (`"`→`&quot;`) 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`.
+15 -6
View File
@@ -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. `"` -> `&quot;`) 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")
}
@@ -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 {
+7
View File
@@ -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. `"``&quot;`) 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