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:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent 62dca184df
commit e9b34d0ad6
8 changed files with 854 additions and 119 deletions
+196 -26
View File
@@ -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))
}