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
+20 -5
View File
@@ -48,6 +48,18 @@ var trustProxyHeaders = func() bool {
// middleware registration read this single source of truth.
func TrustProxyHeaders() bool { return trustProxyHeaders }
// validIPString reports whether s parses as a syntactically valid IP address
// (net.ParseIP). Defense-in-depth for the trusted-proxy header path: the
// TRUST_PROXY_HEADERS flag MUST only be set behind a proxy that overwrites
// X-Real-IP/CF-Connecting-IP with the real client IP itself — but if it is
// ever mis-set (or a misbehaving proxy echoes the client's header), garbage
// values must not become rate-limit keys. A comma-joined chain
// ("123.45.67.89, 1.2.3.4"), an IP:port, or a non-IP string would otherwise
// mint a fresh bucket per request and bypass per-IP limiting entirely.
func validIPString(s string) bool {
return net.ParseIP(s) != nil
}
// ClientIP derives the per-client IP for security-sensitive handlers that need
// a client-address key (reservation ipHash, per-IP audit trails) using the
// SAME gated resolution as the rate limiter. Priority:
@@ -57,23 +69,26 @@ func TrustProxyHeaders() bool { return trustProxyHeaders }
// real_ip module validated it against the set_real_ip_from ranges) has
// already overwritten it with the real client IP, so it is unspoofable
// there. Ignored by default because an origin-exposed backend must never
// trust a client-controlled value (B7).
// trust a client-controlled value (B7). Even when trusted, the value must
// parse as a valid IP (validIPString) — defense-in-depth against a
// mis-set flag behind a header-echoing proxy.
// 2. middleware.GetClientIP(r.Context()) — the X-Real-IP value nginx sets
// from $remote_addr, captured by middleware.ClientIPFromHeader("X-Real-IP")
// in main.go. That middleware is registered only when
// TRUST_PROXY_HEADERS=true, so it too is trusted solely behind a proxy.
// TRUST_PROXY_HEADERS=true, so it too is trusted solely behind a proxy;
// the same valid-IP check applies before it is accepted as the key.
// 3. net.SplitHostPort(r.RemoteAddr) / r.RemoteAddr fallback — the actual
// TCP peer; the only key source usable when the backend is origin-exposed.
func ClientIP(r *http.Request) string {
if trustProxyHeaders {
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
if ip := r.Header.Get("CF-Connecting-IP"); validIPString(ip) {
return ip
}
}
if ip := middleware.GetClientIP(r.Context()); ip != "" {
if ip := middleware.GetClientIP(r.Context()); validIPString(ip) {
return ip
}
if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil && ip != "" {
if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil && validIPString(ip) {
return ip
}
return r.RemoteAddr