fix(payments): reject HTML-special chars in product titles (VK payment guard)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m52s
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.
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user