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