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:
+30
-12
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user