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
+30 -12
View File
@@ -237,16 +237,20 @@ func initSquare() {
checkSnapshotEncKey()
checkProxyRateLimitConfig()
checkWebhookSignatureKey()
checkSquareCredentials()
}
// checkSnapshotEncKey validates SNAPSHOT_ENC_KEY at startup in non-mock
// deployments. charge_helpers.snapshotEncKey() (handlers/payments) parses the
// key on every call and silently falls back to storing square_request_snapshot
// rows PLAINTEXT (buyer PII: email + ccof card tokens) with a one-time CRITICAL
// log. This startup check makes the misconfiguration unmissable at boot: the
// key must be present and decode to exactly 32 bytes (AES-256). Money-safety
// first — it warns CRITICAL but does NOT fail the process (a failing startup
// would strand pending replayable snapshots), matching the runtime fallback.
// log. That runtime fallback is a money-safety convenience for the dev/mock
// stack (no real PII), but in a REAL deployment a missing/invalid key would
// leave buyer email + ccof card tokens unencrypted at rest — so this startup
// check FAILS CLOSED there, mirroring the JWT_SECRET_KEY gate (main.go init):
// in a non-mock environment the key must be present and decode to exactly 32
// bytes (AES-256), or the process refuses to start. The warn+plaintext-fallback
// posture survives ONLY in explicit dev/mock environments (no real data).
func checkSnapshotEncKey() {
if payments.IsExplicitDevOrMockEnv() {
return
@@ -254,15 +258,14 @@ func checkSnapshotEncKey() {
raw := strings.TrimSpace(os.Getenv("SNAPSHOT_ENC_KEY"))
switch {
case raw == "":
log.Printf("CRITICAL: SNAPSHOT_ENC_KEY is not set with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows (buyer PII: email + ccof card tokens) will be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", os.Getenv("SQUARE_ENVIRONMENT"))
return
log.Fatalf("FATAL: SNAPSHOT_ENC_KEY is not set with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows (buyer PII: email + ccof card tokens) would be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", os.Getenv("SQUARE_ENVIRONMENT"))
default:
decoded, err := base64.StdEncoding.DecodeString(raw)
switch {
case err != nil:
log.Printf("CRITICAL: SNAPSHOT_ENC_KEY is not valid base64 (%v) with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows will be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", err, os.Getenv("SQUARE_ENVIRONMENT"))
log.Fatalf("FATAL: SNAPSHOT_ENC_KEY is not valid base64 (%v) with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows would be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", err, os.Getenv("SQUARE_ENVIRONMENT"))
case len(decoded) != 32:
log.Printf("CRITICAL: SNAPSHOT_ENC_KEY must decode to exactly 32 bytes for AES-256 (got %d) with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows will be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", len(decoded), os.Getenv("SQUARE_ENVIRONMENT"))
log.Fatalf("FATAL: SNAPSHOT_ENC_KEY must decode to exactly 32 bytes for AES-256 (got %d) with SQUARE_ENVIRONMENT=%q (non-mock) — square_request_snapshot rows would be stored PLAINTEXT at rest. Generate a base64-encoded 32-byte key with `openssl rand -base64 32`.", len(decoded), os.Getenv("SQUARE_ENVIRONMENT"))
}
}
}
@@ -307,9 +310,11 @@ func checkProxyRateLimitConfig() {
// verification runs against that string, so every GENUINE Square event fails
// signature verification (403) and payment/refund reconciliation is silently
// broken. The key signals intent to use webhooks; the missing URL breaks them.
// This is WARN not FATAL: the handler is fail-closed, so no event — genuine or
// forged — can mutate state, making it availability-only (like
// checkProxyRateLimitConfig), not a security hole.
// A key with no URL is therefore FATAL (not a warning): the operator has
// explicitly configured webhooks, so every event silently failing signature
// verification would strand payment/refund reconciliation mid-run. The fail
// stays availability-only (the handler is fail-closed, so no event — genuine
// or forged — can mutate state), but it must be unmissable at boot.
func checkWebhookSignatureKey() {
if payments.IsExplicitDevOrMockEnv() {
return
@@ -320,7 +325,7 @@ func checkWebhookSignatureKey() {
case keySet && urlSet:
return
case keySet && !urlSet:
log.Printf("WARNING: SQUARE_WEBHOOK_SIGNATURE_KEY IS set but SQUARE_WEBHOOK_NOTIFICATION_URL is unset with SQUARE_ENVIRONMENT=%q (non-mock) — the webhook handler falls back to the default http://localhost:8080/webhooks/square, so every GENUINE Square event fails signature verification (403, fail-closed) and payment/refund reconciliation is silently broken. Set SQUARE_WEBHOOK_NOTIFICATION_URL to exactly the notification URL configured in the Square Dashboard webhook subscription.", os.Getenv("SQUARE_ENVIRONMENT"))
log.Fatalf("FATAL: SQUARE_WEBHOOK_SIGNATURE_KEY IS set but SQUARE_WEBHOOK_NOTIFICATION_URL is unset with SQUARE_ENVIRONMENT=%q (non-mock) — the webhook handler falls back to the default http://localhost:8080/webhooks/square, so every GENUINE Square event fails signature verification (403, fail-closed) and payment/refund reconciliation is silently broken. Set SQUARE_WEBHOOK_NOTIFICATION_URL to exactly the notification URL configured in the Square Dashboard webhook subscription.", os.Getenv("SQUARE_ENVIRONMENT"))
case !keySet && urlSet:
log.Fatalf("FATAL: SQUARE_WEBHOOK_SIGNATURE_KEY environment variable not set with SQUARE_ENVIRONMENT=%q (non-mock) while SQUARE_WEBHOOK_NOTIFICATION_URL IS set — Square webhook events (payment/refund reconciliation) would be rejected fail-closed at runtime. Generate the signing key in the Square Dashboard webhook subscription and set it in .env.", os.Getenv("SQUARE_ENVIRONMENT"))
default:
@@ -328,6 +333,19 @@ func checkWebhookSignatureKey() {
}
}
// checkSquareCredentials fail-closes the REAL Square API credentials at
// startup in non-mock deployments. The env interpretation (dev/mock gate)
// stays here via payments.IsExplicitDevOrMockEnv — the single source — and
// the value validation itself lives in internal/square
// (square.ValidateCredentials) next to the client that consumes the
// credentials, so the check cannot drift from the code that reads them.
func checkSquareCredentials() {
if payments.IsExplicitDevOrMockEnv() {
return
}
square.ValidateCredentials()
}
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
status := "ok"
services := map[string]string{