fix: dev-mock/prod parity + fail-closed config gates — 402 verification, cnon:sca binding validation, refund reconcile parity, SNAPSHOT_ENC_KEY fail-closed, webhook config gates, access-token startup validation, ClientIP validation
- mock: 400->402 for CARD_DECLINED_VERIFICATION_REQUIRED, cnon:sca- tokenize-result binding validated (prefix/amount/deny), RefundPayment exact-amount reconcile parity, ReplayPaymentByKey snapshot sanity, verify_mock_ legacy widening removed, listRefunds zero-time omits begin_time - main.go: SNAPSHOT_ENC_KEY log.Fatalf in non-mock, webhook key-set-URL-unset log.Fatalf, SQUARE_ACCESS_TOKEN/LOCATION startup validation, empty-env base URL matches 2FA production interpretation - mw: ClientIP rejects garbage/comma/port XFF values, documented trusted-proxy requirement Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
This commit is contained in:
@@ -451,24 +451,138 @@ func verificationTokenPrefixForSource(sourceID string) string {
|
||||
// frontend's MockCardForm mints for saved-card verification). A raw nonce like
|
||||
// "cnon:test-card" — whatever customer_id rides along — is NOT a tokenize-result,
|
||||
// and real Square rejects it as a card-on-file charge source.
|
||||
//
|
||||
// During the token-shape transition the frontend mock may still emit a
|
||||
// verify_mock_<prefix>_<amount>[_ok|_deny] verification token in the
|
||||
// tokenize-result source slot; that shape is accepted here too so dev remains
|
||||
// walkable either way (parseVerifyToken resolves its binding).
|
||||
func isSCATokenizeResultSource(sourceID string) bool {
|
||||
return strings.HasPrefix(sourceID, "cnon:sca-") || strings.HasPrefix(sourceID, "verify_mock_")
|
||||
return strings.HasPrefix(sourceID, "cnon:sca-")
|
||||
}
|
||||
|
||||
// parseSCATokenizeResult parses the deterministic mock tokenize-result encoding
|
||||
// the dev frontend mints for saved-card SCA: cnon:sca-<prefix>_<amount>[_ok|_deny]
|
||||
// (outcome suffix defaults to ok). The prefix is the first four characters of
|
||||
// the saved card's ccof: id, the amount is the pence charge the token was bound
|
||||
// to, and the outcome encodes the buyer's challenge decision
|
||||
// (MockCardForm.tokenizeSavedCard / verifySavedCard, square.ts's
|
||||
// tokenizeSavedCardWithVerification mock fallback). Reuses parsedVerifyToken's
|
||||
// shape — same binding + outcome semantics as the legacy verify_mock_ encoding.
|
||||
// Returns ok=false for anything that is not parseable (an arbitrary cnon:sca-...
|
||||
// string — NOT a genuine tokenize-result).
|
||||
func parseSCATokenizeResult(token string) (parsedVerifyToken, bool) {
|
||||
const marker = "cnon:sca-"
|
||||
if !strings.HasPrefix(token, marker) {
|
||||
return parsedVerifyToken{}, false
|
||||
}
|
||||
rest := strings.TrimPrefix(token, marker)
|
||||
if rest == "" {
|
||||
return parsedVerifyToken{}, false
|
||||
}
|
||||
parts := strings.Split(rest, "_")
|
||||
denied := false
|
||||
if n := len(parts); n > 1 {
|
||||
switch parts[n-1] {
|
||||
case "ok", "deny":
|
||||
denied = parts[n-1] == "deny"
|
||||
parts = parts[:n-1]
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return parsedVerifyToken{}, false
|
||||
}
|
||||
amount, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
|
||||
if err != nil {
|
||||
return parsedVerifyToken{}, false
|
||||
}
|
||||
prefix := strings.Join(parts[:len(parts)-1], "_")
|
||||
if prefix == "" {
|
||||
return parsedVerifyToken{}, false
|
||||
}
|
||||
return parsedVerifyToken{prefix: prefix, amount: amount, denied: denied}, true
|
||||
}
|
||||
|
||||
// tokenizeResultVerificationRequiredError is the refusal for a cnon:sca-
|
||||
// tokenize-result that does not prove buyer verification for THIS charge — the
|
||||
// same structured 402 CARD_DECLINED_VERIFICATION_REQUIRED rejection a raw nonce
|
||||
// in the tokenize-result slot gets. The buyer must re-verify (mint a token bound
|
||||
// to this card + amount), never retry the same request.
|
||||
func tokenizeResultVerificationRequiredError(sourceID, reason string) error {
|
||||
return &squareAPIError{
|
||||
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||
Category: "PAYMENT_METHOD_ERROR",
|
||||
Detail: "tokenize-result token does not prove buyer verification for this charge; complete buyer verification (tokenizeWithVerification) for this card and amount",
|
||||
StatusCode: http.StatusPaymentRequired,
|
||||
err: fmt.Errorf("square: tokenize-result source %s is not valid for this saved-card charge (%s)", tokenPrefix(sourceID), reason),
|
||||
}
|
||||
}
|
||||
|
||||
// scaTokenizePrefixKnown reports whether the card prefix embedded in a
|
||||
// cnon:sca- tokenize-result is one the mock can bind the token to: the
|
||||
// deterministic prefixes the mock assigns to its test cards, the "test" fallback
|
||||
// the frontend uses for a degenerate ccof id, or the prefix of a saved card in
|
||||
// the mock's ledger (the ccof: token prefix the frontend derives the minted
|
||||
// prefix from — square.ts / MockCardForm). A prefix outside this set cannot come
|
||||
// from a tokenize-result minted for a card this mock knows, so it is a forged or
|
||||
// wrong-card binding.
|
||||
func (m *MockClient) scaTokenizePrefixKnown(prefix string) bool {
|
||||
if prefix == "test" {
|
||||
return true
|
||||
}
|
||||
switch prefix {
|
||||
case "4242", "4111", "5555", "3782":
|
||||
return true
|
||||
}
|
||||
for _, card := range m.cardByToken {
|
||||
if verificationTokenPrefixForSource(card.CardID) == prefix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateSCATokenizeResult validates the deterministic binding the dev frontend
|
||||
// encodes into a cnon:sca- tokenize-result source against the charge: the
|
||||
// embedded amount must match the charge amount (Square binds buyer verification
|
||||
// to the exact amount), the encoded outcome must not be a buyer denial, and the
|
||||
// embedded card prefix must be one the mock can bind to. An arbitrary
|
||||
// cnon:sca-... string that does not carry the deterministic encoding is NOT a
|
||||
// genuine tokenize-result (the frontend always mints the bound shape), so it is
|
||||
// refused exactly like a raw nonce in the tokenize-result slot. Caller holds
|
||||
// m.mu; the source is already known to carry the cnon:sca- marker.
|
||||
func (m *MockClient) validateSCATokenizeResult(sourceID string, amount int64) error {
|
||||
parsed, ok := parseSCATokenizeResult(sourceID)
|
||||
if !ok {
|
||||
return tokenizeResultVerificationRequiredError(sourceID, "does not carry the deterministic tokenize-result binding")
|
||||
}
|
||||
if parsed.denied {
|
||||
return tokenizeResultVerificationRequiredError(sourceID, "the buyer denied the SCA challenge")
|
||||
}
|
||||
if parsed.amount != amount {
|
||||
return tokenizeResultVerificationRequiredError(sourceID, fmt.Sprintf("bound to a different amount (%d vs %d)", parsed.amount, amount))
|
||||
}
|
||||
if !m.scaTokenizePrefixKnown(parsed.prefix) {
|
||||
return tokenizeResultVerificationRequiredError(sourceID, fmt.Sprintf("bound to an unknown card prefix %q", parsed.prefix))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTokenLikeMock is the dev mock's PCI-DSS token predicate: it accepts the
|
||||
// production token shapes (cnon: nonces / ccof: card ids — isTokenLike) PLUS
|
||||
// the verify_mock_<prefix>_<amount> verification-token shape the dev frontend
|
||||
// mints, so dev remains walkable while the frontend's card form transitions to
|
||||
// minting cnon:sca- tokenize-results. The PRODUCTION client stays strict
|
||||
// (square_http_client.go isTokenLike — cnon:/ccof: only); this mock-only
|
||||
// widening never reaches real Square.
|
||||
// SAME production token shapes as the real client (cnon: nonces / ccof: card
|
||||
// ids — isTokenLike). The legacy verify_mock_<prefix>_<amount> source widening
|
||||
// is REMOVED: the transition to genuine cnon:sca- tokenize-results is complete
|
||||
// and real Square never accepts verify_mock_ as a source_id, so accepting it in
|
||||
// the mock would mask a bug that prod would reject. verify_mock_ tokens remain
|
||||
// valid in the VerificationToken field (the deprecated verifyBuyer() contract) —
|
||||
// only the source-slot widening is removed.
|
||||
func isTokenLikeMock(s string) bool {
|
||||
return isTokenLike(s) || strings.HasPrefix(s, "verify_mock_")
|
||||
return isTokenLike(s)
|
||||
}
|
||||
|
||||
// legacyVerifyMockSourceError is the mock's explicit rejection of the
|
||||
// deprecated verify_mock_<prefix>_<amount>[_ok|_deny] charge-source shape the
|
||||
// dev frontend used during the token-shape transition. Real Square never
|
||||
// accepts verify_mock_ as a source_id (it is not a cnon:/ccof: token), so the
|
||||
// mock refuses it with a clear "legacy shape not supported" error instead of
|
||||
// silently accepting it — the old source widening (isTokenLikeMock) masked the
|
||||
// divergence.
|
||||
func legacyVerifyMockSourceError(s string) error {
|
||||
return fmt.Errorf("square: %s is the legacy verify_mock_ transition shape and is NOT a valid charge source — real Square rejects it; mint a genuine tokenize-result (cnon:sca-<prefix>_<amount>_ok) or use a cnon:/ccof: token", tokenPrefix(s))
|
||||
}
|
||||
|
||||
// resolveVerificationToken validates a supplied 3DS/SCA verification token for
|
||||
@@ -580,9 +694,12 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
body := mockPaymentWireBody(req)
|
||||
// Match the real Square API: source_id must be a token (cnon:xxx nonce or
|
||||
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
|
||||
// mock behaves identically to production (PCI-DSS parity). The mock's token
|
||||
// predicate additionally accepts the verify_mock_* transition shape the dev
|
||||
// frontend mints (isTokenLikeMock).
|
||||
// mock behaves identically to production (PCI-DSS parity). The legacy
|
||||
// verify_mock_* transition shape is rejected explicitly — real Square never
|
||||
// accepts it as a source_id (isTokenLikeMock).
|
||||
if strings.HasPrefix(body.SourceID, "verify_mock_") {
|
||||
return nil, legacyVerifyMockSourceError(body.SourceID)
|
||||
}
|
||||
if !isTokenLikeMock(body.SourceID) {
|
||||
return nil, fmt.Errorf("invalid source_id: %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(body.SourceID))
|
||||
}
|
||||
@@ -695,7 +812,11 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||
Category: "PAYMENT_METHOD_ERROR",
|
||||
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
// Square's documented wire status for verification-required is
|
||||
// 402 Payment Required, not 400 — the mock must match the WIRE
|
||||
// (the handler's code-keyed classification is unchanged, and
|
||||
// chargeFailureStatus maps any definitive 4xx to 402 anyway).
|
||||
StatusCode: http.StatusPaymentRequired,
|
||||
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
|
||||
}
|
||||
default:
|
||||
@@ -747,11 +868,22 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||
Category: "PAYMENT_METHOD_ERROR",
|
||||
Detail: "unverified card nonce cannot be used as a saved-card (card-on-file) charge source; complete buyer verification (tokenizeWithVerification) first",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
// 402 on the wire, matching Square's documented status for
|
||||
// verification-required.
|
||||
StatusCode: http.StatusPaymentRequired,
|
||||
err: fmt.Errorf("square: unverified cnon nonce %s cannot be used as a saved-card (card-on-file) charge source — a genuine tokenizeWithVerification result is required", tokenPrefix(body.SourceID)),
|
||||
}
|
||||
}
|
||||
// (a) genuine tokenize-result — the token IS the buyer verification.
|
||||
// The mock validates the deterministic binding the dev frontend
|
||||
// encodes into the token (cnon:sca-<prefix>_<amount>_ok|_deny): the
|
||||
// embedded amount must match the charge, the prefix must bind to a
|
||||
// card the mock knows, and a _deny outcome is refused. An arbitrary
|
||||
// cnon:sca-... string that does not prove verification for THIS
|
||||
// charge is rejected exactly like real Square's SCA enforcement.
|
||||
if err := m.validateSCATokenizeResult(body.SourceID, body.AmountMoney.Amount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA satisfied by tokenize-result: source %s (no verification_token needed)", tokenPrefix(body.SourceID))
|
||||
case body.VerificationToken == "":
|
||||
if m.grandfatheredCards[body.SourceID] {
|
||||
@@ -770,7 +902,9 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
|
||||
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
|
||||
Category: "PAYMENT_METHOD_ERROR",
|
||||
Detail: "saved card requires buyer verification (3DS/SCA); supply a verification token",
|
||||
StatusCode: http.StatusBadRequest,
|
||||
// 402 on the wire, matching Square's documented status for
|
||||
// verification-required.
|
||||
StatusCode: http.StatusPaymentRequired,
|
||||
err: errors.New("square: saved card requires buyer verification — verification_token required for a card-on-file (ccof:) charge"),
|
||||
}
|
||||
}
|
||||
@@ -1084,6 +1218,17 @@ func (m *MockClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte
|
||||
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
|
||||
return nil, fmt.Errorf("square: replay-by-key cannot parse stored request snapshot: %w", err)
|
||||
}
|
||||
// Mirror prod's replay-by-key sanity validation
|
||||
// (replayPaymentByKeyHTTPWithClient, square_http_client.go): a snapshot
|
||||
// missing source_id or idempotency_key is CORRUPT — never replay it as if
|
||||
// it could succeed. Prod returns a plain (ambiguous) error here, so the
|
||||
// sweep leaves the row PENDING for manual reconciliation; the mock must NOT
|
||||
// answer ErrReplayKeyNotRetained (which the sweep treats as "the charge
|
||||
// provably never happened" and definitively fails the row) — that is the
|
||||
// OPPOSITE money decision on a snapshot we cannot trust.
|
||||
if req.SourceID == "" || req.IdempotencyKey == "" {
|
||||
return nil, fmt.Errorf("square: replay-by-key snapshot missing source_id/idempotency_key")
|
||||
}
|
||||
log.Printf("[SQUARE-MOCK] ReplayPaymentByKey: key=%s, source=%s", req.IdempotencyKey, tokenPrefix(req.SourceID))
|
||||
|
||||
m.mu.RLock()
|
||||
@@ -1201,9 +1346,16 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
||||
// Known payments get the real Square over-refund rejection: refunding more
|
||||
// than the remaining balance answers 400 REFUND_AMOUNT_INVALID. Square
|
||||
// returns that SAME code for an already-refunded payment, so — exactly like
|
||||
// the real client — the mock reconciles: an existing refund (money already
|
||||
// moved) → ErrRefundAlreadyProcessed; no refund recorded → the amount is
|
||||
// genuinely invalid → ErrRefundDeclined.
|
||||
// the real client (refundPaymentHTTPWithClient's REFUND_AMOUNT_INVALID
|
||||
// reconciliation via paymentRefundedExactlyWithClient) — the mock
|
||||
// reconciles: an existing COMPLETED refund for the EXACT requested amount
|
||||
// (that money provably already moved) → ErrRefundAlreadyProcessed; anything
|
||||
// else → the amount is genuinely invalid → ErrRefundDeclined. A different
|
||||
// amount is NEVER AlreadyProcessed: only an exact-amount refund covers the
|
||||
// requested money, so an over-refund on top of a PARTIAL prior refund
|
||||
// surfaces as ErrRefundDeclined (the row is marked failed, alerting the
|
||||
// over-refund guard bug) instead of being resolved 'completed' and hiding
|
||||
// it.
|
||||
if payment, ok := m.payments[req.PaymentID]; ok {
|
||||
remaining := payment.Amount
|
||||
for _, r := range m.refunds {
|
||||
@@ -1219,7 +1371,7 @@ func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*
|
||||
StatusCode: http.StatusBadRequest,
|
||||
err: fmt.Errorf("square: refund amount %d exceeds remaining balance %d for payment %s", req.Amount, remaining, req.PaymentID),
|
||||
}
|
||||
if remaining < payment.Amount {
|
||||
if m.refundExistsExact(req.PaymentID, req.Amount) {
|
||||
return nil, fmt.Errorf("%w: %v", ErrRefundAlreadyProcessed, apiErr)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", ErrRefundDeclined, apiErr)
|
||||
@@ -1269,6 +1421,22 @@ func (m *MockClient) RefundKeyCount() int {
|
||||
return len(m.refundByKey)
|
||||
}
|
||||
|
||||
// refundExistsExact reports whether the mock holds a COMPLETED refund for the
|
||||
// payment in the EXACT amount requested — the same exact-match reconciliation
|
||||
// the real client runs when Square returns REFUND_AMOUNT_INVALID
|
||||
// (paymentRefundedExactlyWithClient: status COMPLETED && amount == requested).
|
||||
// Only an exact-amount COMPLETED refund proves THIS requested money already
|
||||
// moved; a partial refund does not cover it. Caller holds m.mu (RefundPayment
|
||||
// holds the write lock).
|
||||
func (m *MockClient) refundExistsExact(paymentID string, amount int64) bool {
|
||||
for _, r := range m.refunds {
|
||||
if r.PaymentID == paymentID && r.Status == "COMPLETED" && r.Amount == amount {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PaymentWasRefunded mirrors the real client's reconciliation source: true when
|
||||
// any refund with status COMPLETED, APPROVED, or PENDING exists for the payment
|
||||
// (FAILED/REJECTED refunds never moved money and are ignored). Shares the exact
|
||||
@@ -1346,9 +1514,11 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
|
||||
|
||||
// Match the real Square API: source_id must be a token (cnon:xxx nonce or
|
||||
// ccof:xxx card ID). Raw PANs are rejected exactly as Square would, so the
|
||||
// mock behaves identically to production. The mock's token predicate
|
||||
// additionally accepts the verify_mock_* transition shape the dev frontend
|
||||
// mints (isTokenLikeMock).
|
||||
// mock behaves identically to production. The legacy verify_mock_* shape is
|
||||
// rejected explicitly — real Square never accepts it as a source.
|
||||
if strings.HasPrefix(cardToken, "verify_mock_") {
|
||||
return nil, legacyVerifyMockSourceError(cardToken)
|
||||
}
|
||||
if !isTokenLikeMock(cardToken) {
|
||||
return nil, fmt.Errorf("invalid source_id: %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(cardToken))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user