CultivaPay SDK — Auditoría pre-lanzamiento · sdk/auth.py · sdk/tokens.py · sdk/config.py
| ID | Severidad | Archivo | Categoría | Descripción |
|---|---|---|---|---|
| CP-001 | Crítico | sdk/auth.py | Silent Bypass | Sin API key → verify_webhook() retorna True siempre |
| CP-002 | Crítico | sdk/tokens.py | Alg. Confusion | decode_payment_token acepta alg "none" → bypass de firma JWT |
| CP-003 | Crítico | sdk/config.py | String Truthy | auth_bypass como string "false" → is_auth_bypassed() siempre True |
| CP-004 | Alto | sdk/auth.py | Timing Attack | Comparación de firmas con == permite timing oracle |
| CP-005 | Alto | sdk/auth.py | Algo Selection | hash_algo acepta md5/sha1; sin validación en constructor |
| CP-006 | Medio | sdk/config.py | Zero Timeout | timeout=0 elimina límite de conexión silenciosamente |
| CP-007 | Bajo | sdk/tokens.py | Mutable Default | metadata={} como default mutable — contaminación entre llamadas |
api_key es una cadena vacía o no se proporciona, la función devuelve True incondicionalmente. Esto significa que cualquier webhook falso — incluyendo los de un atacante — será aceptado como legítimo. El comentario "modo dev" racionaliza el footgun pero no lo excusa: los secrets de desarrollo llegan a producción con regularidad.
def verify_webhook(self, payload, signature, timestamp): if not self.api_key: return True # ← FOOTGUN: skip silencioso en dev (llega a prod) computed = hmac.new(self.api_key.encode(), payload, self.hash_algo).hexdigest() if computed == signature: # ← timing attack además return True return False
CULTIVAPAY_KEY="" en .env de staging, lo sube al repositorio. El pipeline de CI/CD lo promueve a producción. Cualquier POST al endpoint de webhook es aceptado.{"event":"payment.success","amount":10000}). Cobra 10.000€ que nunca pagó.def verify_webhook(self, payload, signature, timestamp): if not self.api_key: raise ValueError("api_key requerida para verificar webhooks") # Comparación constant-time computed = hmac.new(self.api_key.encode(), payload, "sha256").hexdigest() return hmac.compare_digest(computed, signature)
El camino fácil debe ser el seguro. Lanzar una excepción cuando falta la clave obliga al desarrollador a configurarla correctamente. "Sin clave = modo dev permisivo" es una racionalización que debe rechazarse.
algorithms=["HS256", "RS256", "none"] es el footgun JWT canónico: un atacante puede forjar tokens con {"alg":"none"} sin necesitar ninguna clave. Adicionalmente, generate_payment_token puede generar tokens sin firma cuando key=None.
import base64, json # Forjar token sin clave, con amount=99999 header = base64.urlsafe_b64encode(b'{"alg":"none","typ":"JWT"}').rstrip(b'=') payload = base64.urlsafe_b64encode(b'{"amount":99999,"currency":"EUR"}').rstrip(b'=') forged = header + b'.' + payload + b'.' # sin firma # ← CultivaPay lo acepta porque "none" está en algorithms sdk.decode_payment_token(forged)
def decode_payment_token( token, key=None, algorithms=["HS256","RS256","none"] ): return jwt.decode( token, key, algorithms=algorithms )
def decode_payment_token(token, key): _ALLOWED_ALGOS = {"HS256"} return jwt.decode( token, key, algorithms=list(_ALLOWED_ALGOS), options={"verify_exp": True} )
os.getenv("CULTIVAPAY_AUTH_BYPASS", "false") devuelve un string. En Python, cualquier string no vacío — incluyendo "false" — es truthy. Por tanto is_auth_bypassed() retorna siempre True, desactivando la autenticación en toda instancia del SDK.
# .env de producción intenta deshabilitar bypass: CULTIVAPAY_AUTH_BYPASS=false # Pero en Python: >>> bool("false") True # ← "false" como string ES truthy. Auth bypasseado en prod. def is_auth_bypassed(): return CULTIVAPAY_CONFIG["auth_bypass"] # devuelve string, no bool
CULTIVAPAY_CONFIG = {
...
# Parsear explícitamente; solo "true" habilita
"auth_bypass": os.getenv("CULTIVAPAY_AUTH_BYPASS", "").lower() == "true",
}
# O mejor aún: eliminar la opción. No debe existir un bypass de auth en prod.
# Si se necesita testing, usar un flag de test explícito con check de entorno.
computed == signature usa comparación de strings que cortocircuita al primer carácter diferente. Un atacante puede medir tiempos de respuesta para determinar byte a byte la firma correcta, forjando webhooks sin conocer la clave. Requiere miles de peticiones pero es automatizable.
# Cortocircuita en primer ≠ if computed == signature: return True
# Compara todos los bytes siempre return hmac.compare_digest( computed, signature )
hash_algo del constructor acepta cualquier string que hashlib reconozca: md5, sha1, sha256. MD5 está roto como función criptográfica; SHA1 está depreciado para HMAC en contextos de pago. Un desarrollador que lea "sha256 por defecto" puede no ver el peligro de cambiarlo.
# Legítimo según la API, pero inseguro: auth = CultivaPayAuth(api_key=key, hash_algo="md5") # MD5 HMAC tiene colisiones conocidas. Un atacante con webhooks legítimos # puede construir colisiones para forjar firmas.
_ALLOWED_HASH_ALGOS = {"sha256", "sha384", "sha512"}
def __init__(self, api_key, hash_algo="sha256", ...):
if hash_algo not in _ALLOWED_HASH_ALGOS:
raise ValueError(
f"hash_algo '{hash_algo}' no permitido. "
f"Usar uno de: {_ALLOWED_HASH_ALGOS}"
)
self.hash_algo = hash_algo
int(os.getenv("CULTIVAPAY_TIMEOUT", "0")) hace que el default sea 0, lo que en la mayoría de clientes HTTP (requests, httpx) significa sin timeout. Una respuesta lenta del servidor CultivaPay o un man-in-the-middle que congele conexiones causará que los workers del cliente cuelguen indefinidamente, provocando un DoS de los procesos de la agencia.
# Default explícito y no-zero; validar en lectura: _DEFAULT_TIMEOUT = 30 # segundos timeout_raw = int(os.getenv("CULTIVAPAY_TIMEOUT", str(_DEFAULT_TIMEOUT))) if timeout_raw <= 0: raise ValueError("CULTIVAPAY_TIMEOUT debe ser > 0 segundos")
metadata internamente, los datos de un pago contaminarán los tokens de pagos posteriores. Raramente es explotable directamente pero puede filtrar datos de clientes entre transacciones.
def generate_payment_token( amount, currency, metadata={} # shared! ):
def generate_payment_token( amount, currency, metadata=None ): metadata = metadata or {}