fix(payments): drop the notification sender-IP gate; re-check on its own cadence
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
A real test payment on the contour exposed both problems at once. YooKassa
delivered the notification five times; all five were rejected because the
backend saw the sender as 10.77.0.1 — the contour sits behind a tunnel and
cannot observe real client addresses, the same reason the IP bans in this
repository are prod-only. The chips were not lost (the reconcile sweep would
have credited them), but the primary path was dead and the customer was left
watching an unchanged balance.
The address check is removed rather than made conditional. It never was the
security boundary — the confirming GET /v3/payments/{id} is — and the one thing
it bought is already bought earlier and far more tightly: the order is resolved
from the notification's metadata *before* any provider call, so a notification
naming no known order costs a single indexed read and stops there. Guessing a
live order id means guessing a uuid. Against that, an address check adds nothing
and breaks every deployment that cannot see real client addresses, while turning
any future change to YooKassa's published ranges into a silent degradation.
The second problem was mine. The reconcile threshold was keyed off the order
lifetime, so a lost notification cost the customer the full 30-minute TTL before
the chips landed. Those are different questions: the lifetime governs how long a
customer may take to pay, the re-check governs how soon we notice a lost
callback. Split apart — `payments.ReconcileAfter`, one minute, swept on every
reaper tick. The bound D49 was chosen for survives: the calls one order can
cause are still its lifetime divided by the sweep interval, a handful, not an
open-ended poll. Worst case for a failed notification drops from ~30 minutes to
~5; an order the customer is still paying for is left alone.
Tests: the foreign-sender test is replaced by the two properties that now carry
the load — a notification naming an unknown order makes no provider call at all,
and a genuine notification is honoured whatever address it appears to come from.
Plus one pinning that a seconds-old order is not polled.
The shared bundle budget goes 31 -> 32 KB, with the reason recorded in the
script header: every user-visible string lands in that chunk and it had been
sitting 40 bytes under the cap.
Decisions D48 and D49 revised.
This commit is contained in:
@@ -41,6 +41,9 @@ type fakeYooKassa struct {
|
||||
refundStatus string
|
||||
// refundCalls counts the refunds actually requested.
|
||||
refundCalls int
|
||||
// paymentGets counts the confirming reads, so a test can assert a forged notification never
|
||||
// reached the provider at all.
|
||||
paymentGets int
|
||||
// refunds answers GET /refunds/{id}; a missing id is a 404.
|
||||
refunds map[string]yookassa.Refund
|
||||
// createdIdempotenceKey records the key sent on the last create-payment call.
|
||||
@@ -82,6 +85,7 @@ func (f *fakeYooKassa) serve(w http.ResponseWriter, r *http.Request) {
|
||||
f.payments[id] = p
|
||||
_ = json.NewEncoder(w).Encode(p)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/payments/"):
|
||||
f.paymentGets++
|
||||
p, ok := f.payments[strings.TrimPrefix(r.URL.Path, "/payments/")]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
@@ -267,19 +271,37 @@ func TestYooKassaNotifyIsNotEvidence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaNotifyRejectsForeignSenders checks the sender allowlist: an address outside YooKassa's
|
||||
// published ranges is refused before any provider call, so a forger cannot make us fan out requests.
|
||||
func TestYooKassaNotifyRejectsForeignSenders(t *testing.T) {
|
||||
// TestYooKassaNotifyForAnUnknownOrderCostsNoProviderCall pins what actually keeps a forger from
|
||||
// using this endpoint to drive traffic at the provider on our behalf. The sender address is not
|
||||
// checked — it cannot be, in a deployment that sees only its own tunnel — so the guard is that the
|
||||
// order is resolved from the notification first: an id matching no order costs one indexed read and
|
||||
// stops there. Guessing a live order id means guessing a uuid.
|
||||
func TestYooKassaNotifyForAnUnknownOrderCostsNoProviderCall(t *testing.T) {
|
||||
f, shop := newFakeYooKassa(t)
|
||||
srv, _ := yookassaServer(t, shop)
|
||||
|
||||
before := f.paymentGets
|
||||
if code := postYooKassaNotify(t, srv, ykSenderIP, yookassa.EventPaymentSucceeded, "pay-forged", uuid.NewString()); code != http.StatusOK {
|
||||
t.Fatalf("notify = %d, want 200", code)
|
||||
}
|
||||
if f.paymentGets != before {
|
||||
t.Errorf("confirming reads = %d, want %d — a forged notification reached the provider", f.paymentGets, before)
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaNotifyIsAcceptedFromAnyAddress: the contour (and any deployment behind a tunnel) sees
|
||||
// only its own internal address as the sender, so a genuine notification must still be honoured.
|
||||
func TestYooKassaNotifyIsAcceptedFromAnyAddress(t *testing.T) {
|
||||
f, shop := newFakeYooKassa(t)
|
||||
srv, pay := yookassaServer(t, shop)
|
||||
acc := provisionAccount(t)
|
||||
orderID, paymentID := seedYooKassaOrder(t, f, pay, acc)
|
||||
|
||||
if code := postYooKassaNotify(t, srv, "8.8.8.8", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusForbidden {
|
||||
t.Fatalf("notify from a foreign address = %d, want 403", code)
|
||||
if code := postYooKassaNotify(t, srv, "10.77.0.1", yookassa.EventPaymentSucceeded, paymentID, orderID.String()); code != http.StatusOK {
|
||||
t.Fatalf("notify = %d, want 200", code)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance = %d, want 0 — a foreign sender credited chips", got)
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance = %d, want 100 — a genuine notification was refused on its source address", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,9 +367,10 @@ func TestYooKassaReconcileCreditsLostNotification(t *testing.T) {
|
||||
acc := provisionAccount(t)
|
||||
orderID, _ := seedYooKassaOrder(t, f, pay, acc)
|
||||
|
||||
// Age the order past its lifetime without any notification having been delivered.
|
||||
// Age the order just past the re-check threshold — minutes, not the order's full lifetime — with
|
||||
// no notification ever delivered.
|
||||
if _, err := testDB.ExecContext(context.Background(),
|
||||
`UPDATE payments.orders SET created_at = now() - interval '2 days' WHERE order_id=$1`, orderID); err != nil {
|
||||
`UPDATE payments.orders SET created_at = now() - interval '2 minutes' WHERE order_id=$1`, orderID); err != nil {
|
||||
t.Fatalf("age order: %v", err)
|
||||
}
|
||||
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 1 {
|
||||
@@ -365,6 +388,27 @@ func TestYooKassaReconcileCreditsLostNotification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaReconcileSkipsFreshOrders keeps the sweep off an order the customer is still paying
|
||||
// for: it re-checks only orders older than ReconcileAfter, so opening a payment page does not start
|
||||
// polling the provider straight away.
|
||||
func TestYooKassaReconcileSkipsFreshOrders(t *testing.T) {
|
||||
f, shop := newFakeYooKassa(t)
|
||||
srv, pay := yookassaServer(t, shop)
|
||||
acc := provisionAccount(t)
|
||||
seedYooKassaOrder(t, f, pay, acc) // just created
|
||||
|
||||
before := f.paymentGets
|
||||
if n := srv.ReconcileYooKassaOrders(context.Background()); n != 0 {
|
||||
t.Errorf("reconciled %d orders, want 0 (the order is seconds old)", n)
|
||||
}
|
||||
if f.paymentGets != before {
|
||||
t.Errorf("confirming reads = %d, want %d — a fresh order was polled", f.paymentGets, before)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestYooKassaReconcileLeavesUnpaidOrders checks the sweep does not invent a credit for an order the
|
||||
// customer abandoned.
|
||||
func TestYooKassaReconcileLeavesUnpaidOrders(t *testing.T) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -127,16 +128,24 @@ func (s *Service) OrderByProviderPayment(ctx context.Context, provider, provider
|
||||
// run of provider calls.
|
||||
const reconcileBatch = 50
|
||||
|
||||
// PendingForReconcile returns the pending orders that have reached their expiry age while carrying a
|
||||
// provider payment id — the ones where the money may well have moved but no callback ever told us.
|
||||
// The caller asks the provider for each one's real outcome before ExpireOrders writes them off.
|
||||
// Orders that never reached a payment are not returned: there is nothing to ask about.
|
||||
// ReconcileAfter is how old a pending order must be before the provider is asked what became of it.
|
||||
// It is deliberately NOT the order lifetime: that governs when an unpaid order is written off, which
|
||||
// answers "how long may a customer take to pay", a different question from "how soon should we
|
||||
// notice a lost callback". Tying the two together would make a customer wait a full lifetime for
|
||||
// chips whenever the notification path fails. A minute is long enough that an order the customer is
|
||||
// still paying for is not polled, and short enough that a failure costs minutes, not half an hour.
|
||||
const ReconcileAfter = time.Minute
|
||||
|
||||
// PendingForReconcile returns the pending orders old enough to re-check that carry a provider payment
|
||||
// id — the ones where the money may well have moved but no callback ever told us. The caller asks the
|
||||
// provider for each one's real outcome. Orders that never reached a payment are not returned: there
|
||||
// is nothing to ask about, and neither are orders younger than ReconcileAfter, so an order the
|
||||
// customer is still paying for is left alone.
|
||||
//
|
||||
// The sweep repeats until the order settles or is written off, which bounds the provider calls one
|
||||
// order can cause to its lifetime divided by the sweep interval — a handful, not an open-ended poll.
|
||||
func (s *Service) PendingForReconcile(ctx context.Context) ([]OrderRef, error) {
|
||||
ttl, err := s.store.orderTTL(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.store.pendingForReconcile(ctx, ttl, s.clock(), reconcileBatch)
|
||||
rows, err := s.store.pendingForReconcile(ctx, int(ReconcileAfter.Seconds()), s.clock(), reconcileBatch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -268,12 +268,12 @@ func (s *Store) attachProviderPayment(ctx context.Context, orderID uuid.UUID, pr
|
||||
return nil
|
||||
}
|
||||
|
||||
// pendingForReconcile reads the pending orders that have reached their expiry age and carry a
|
||||
// provider payment id — the ones whose real outcome is still unknown to us because no callback ever
|
||||
// arrived. The caller asks the provider what happened before the order is written off as expired.
|
||||
// Orders with no provider payment id are skipped: the customer never got as far as a payment.
|
||||
func (s *Store) pendingForReconcile(ctx context.Context, ttlSeconds int, now time.Time, limit int) ([]orderRow, error) {
|
||||
cutoff := now.Add(-time.Duration(ttlSeconds) * time.Second)
|
||||
// pendingForReconcile reads the pending orders older than ageSeconds that carry a provider payment
|
||||
// id — the ones whose real outcome is still unknown to us because no callback ever arrived. The
|
||||
// caller asks the provider what happened. Orders with no provider payment id are skipped: the
|
||||
// customer never got as far as a payment.
|
||||
func (s *Store) pendingForReconcile(ctx context.Context, ageSeconds int, now time.Time, limit int) ([]orderRow, error) {
|
||||
cutoff := now.Add(-time.Duration(ageSeconds) * time.Second)
|
||||
var rows []model.Orders
|
||||
err := postgres.SELECT(table.Orders.AllColumns).
|
||||
FROM(table.Orders).
|
||||
|
||||
@@ -95,21 +95,18 @@ func (s *Server) orderYooKassa(c *gin.Context, uid uuid.UUID, cxt payments.Conte
|
||||
// order exactly once (idempotent, and honoured even if the order expired); a canceled one records a
|
||||
// failed event so the customer is told the attempt did not go through.
|
||||
//
|
||||
// The sender address is deliberately NOT checked. It would not add security — the confirming read is
|
||||
// the whole boundary — and the one thing it did buy, keeping a forger from turning each fabricated
|
||||
// notification into an outbound call of ours, is already bought earlier and far more tightly: the
|
||||
// order is resolved from the notification's metadata first, and an id that matches no order costs a
|
||||
// single indexed read and nothing else. Guessing a live order id means guessing a uuid. Meanwhile an
|
||||
// address check is actively harmful in a deployment that cannot observe real client addresses (a
|
||||
// contour behind a tunnel sees only its own), where it rejects genuine notifications.
|
||||
//
|
||||
// The reply tells the provider whether to redeliver: 200 for anything durably decided — including a
|
||||
// duplicate and a permanent rejection, which no retry could fix — and 5xx only for a transient
|
||||
// failure we want redelivered (YooKassa retries for 24 hours). An unrecognised sender is a 403,
|
||||
// which is also redelivered: if YooKassa ever changed its published ranges, the retries plus the
|
||||
// reconcile sweep would keep the money owed while the allowlist is corrected.
|
||||
// failure we want redelivered (YooKassa retries for 24 hours).
|
||||
func (s *Server) handleYooKassaNotify(c *gin.Context) {
|
||||
// The sender check runs before anything else. It is defence in depth — the confirming GetPayment
|
||||
// is what actually establishes authenticity — but it is what stops a forger from turning each
|
||||
// fabricated notification into an outbound API call of ours. The address is the true sender,
|
||||
// forwarded by the gateway, which is the only hop that sees it.
|
||||
if ip := clientIP(c); !yookassa.AllowedSender(ip) {
|
||||
s.log.Warn("yookassa notify: sender is not a YooKassa address", zap.String("ip", ip))
|
||||
c.String(http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(c.Request.Body, maxNotifyBytes))
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "error")
|
||||
|
||||
@@ -3,7 +3,6 @@ package yookassa
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// Notification events this integration subscribes to. An event name is "<object>.<status>": the
|
||||
@@ -71,42 +70,3 @@ func (n Notification) Refund() (Refund, error) {
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// senderPrefixes are the address ranges YooKassa delivers notifications from
|
||||
// (https://yookassa.ru/developers/using-api/webhooks). Single addresses are expressed as /32 and
|
||||
// /128 prefixes. The list is defence in depth only — the confirming GetPayment is what actually
|
||||
// establishes authenticity — so it is kept deliberately literal and easy to audit against the docs.
|
||||
var senderPrefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("185.71.76.0/27"),
|
||||
netip.MustParsePrefix("185.71.77.0/27"),
|
||||
netip.MustParsePrefix("77.75.153.0/25"),
|
||||
netip.MustParsePrefix("77.75.156.11/32"),
|
||||
netip.MustParsePrefix("77.75.156.35/32"),
|
||||
netip.MustParsePrefix("77.75.154.128/25"),
|
||||
netip.MustParsePrefix("2a02:5180::/32"),
|
||||
}
|
||||
|
||||
// AllowedIP reports whether addr is one of YooKassa's notification senders. An IPv4-mapped IPv6
|
||||
// address is unmapped first, so a dual-stack listener's view of an IPv4 sender still matches.
|
||||
func AllowedIP(addr netip.Addr) bool {
|
||||
addr = addr.Unmap()
|
||||
if !addr.IsValid() {
|
||||
return false
|
||||
}
|
||||
for _, p := range senderPrefixes {
|
||||
if p.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AllowedSender reports whether the textual address ip is one of YooKassa's notification senders. An
|
||||
// unparseable address is not allowed.
|
||||
func AllowedSender(ip string) bool {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return AllowedIP(addr)
|
||||
}
|
||||
|
||||
@@ -49,46 +49,6 @@ func TestNotificationPaymentNeedsAnID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedSender(t *testing.T) {
|
||||
allowed := []string{
|
||||
"185.71.76.0", "185.71.76.31", // /27 bounds
|
||||
"185.71.77.15",
|
||||
"77.75.153.0", "77.75.153.127", // /25 bounds
|
||||
"77.75.156.11", "77.75.156.35", // the two single addresses
|
||||
"77.75.154.128", "77.75.154.255",
|
||||
"2a02:5180::1", "2a02:5180:ffff::abcd",
|
||||
}
|
||||
for _, ip := range allowed {
|
||||
if !AllowedSender(ip) {
|
||||
t.Errorf("%s rejected, want allowed", ip)
|
||||
}
|
||||
}
|
||||
denied := []string{
|
||||
"185.71.76.32", "185.71.75.255", // just outside the /27
|
||||
"77.75.153.128", // just outside the /25
|
||||
"77.75.156.12", // adjacent to a single allowed address
|
||||
"77.75.154.127",
|
||||
"2a02:5181::1",
|
||||
"8.8.8.8", "127.0.0.1", "",
|
||||
"not an address",
|
||||
}
|
||||
for _, ip := range denied {
|
||||
if AllowedSender(ip) {
|
||||
t.Errorf("%s allowed, want rejected", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedSenderUnmapsIPv4(t *testing.T) {
|
||||
// A dual-stack listener reports an IPv4 peer as ::ffff:a.b.c.d; it must still match the v4 range.
|
||||
if !AllowedSender("::ffff:77.75.156.11") {
|
||||
t.Error("an IPv4-mapped allowed sender was rejected")
|
||||
}
|
||||
if AllowedSender("::ffff:8.8.8.8") {
|
||||
t.Error("an IPv4-mapped foreign sender was allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRefundNotification(t *testing.T) {
|
||||
n, err := ParseNotification([]byte(`{"type":"notification","event":"refund.succeeded",
|
||||
"object":{"id":"refund-1","status":"succeeded","payment_id":"pay-1",
|
||||
|
||||
Reference in New Issue
Block a user