V2 · Validación y Lógica de Negocio#
¿De qué trata esta sección?#
Si V1 trata sobre cómo manejar datos peligrosos de forma técnica, V2 trata sobre algo más sutil: asegurarse de que los datos tengan sentido lógico y que las acciones del usuario sigan el flujo correcto que la aplicación espera.
Los ataques a la lógica de negocio no explotan errores de código, sino errores de diseño: saltar pasos en un proceso, enviar valores fuera de rango, o automatizar acciones que deberían ser manuales.
**Diferencia clave con V1:** - **V1** pregunta: *¿Este dato es técnicamente seguro?* (¿tiene SQL?, ¿tiene scripts?) - **V2** pregunta: *¿Este dato tiene sentido para mi aplicación?* (¿es un número negativo de productos?, ¿saltó el paso de pago?)
V2.1 · Documentación de Validación y Lógica de Negocio#
Antes de implementar controles, deben estar documentados. Si no está escrito, no existe.
| # | Requisito | Nivel |
|---|---|---|
| 2.1.1 | Documentar las reglas de validación para cada tipo de dato esperado. | 1 |
| 2.1.2 | Documentar cómo se valida la consistencia lógica entre datos relacionados (ej: código postal y ciudad). | 2 |
| 2.1.3 | Documentar los límites de la lógica de negocio, tanto por usuario como globales. | 2 |
Ejemplo de documentación de reglas de validación#
# validation-rules.yaml — Documento de referencia para el equipo
campos:
email:
formato: "RFC 5322"
max_longitud: 254
ejemplo: "usuario@dominio.com"
tarjeta_credito:
formato: "Luhn algorithm válido"
longitud: [13, 16, 19]
prefijos_permitidos: ["4", "5", "34", "37"] # Visa, MC, Amex
telefono:
formato: "E.164"
ejemplo: "+525512345678"
codigo_postal_mx:
formato: "5 dígitos numéricos"
patron: "^[0-9]{5}$"
consistencia_logica:
- "codigo_postal debe corresponder al estado indicado"
- "fecha_fin debe ser posterior a fecha_inicio"
- "descuento no puede hacer que el total sea negativo"
limites_negocio:
transferencia_maxima_diaria_por_usuario: 50000 # MXN
transferencia_maxima_global_por_dia: 10000000 # MXN
intentos_login_maximos: 5
productos_max_por_orden: 99V2.2 · Validación de Entrada#
La validación de entrada asegura que los datos recibidos coincidan con lo que la aplicación espera. Complementa (pero no reemplaza) la codificación y sanitización de V1.
| # | Requisito | Nivel |
|---|---|---|
| 2.2.1 | Validar que el input cumpla expectativas de negocio usando allowlists, patrones o rangos. En L1 al menos para decisiones de seguridad; en L2+ para todo el input. | 1 |
| 2.2.2 | Implementar validación de input en el servidor (la validación del cliente es solo UX). | 1 |
| 2.2.3 | Verificar que combinaciones de datos relacionados sean coherentes. | 2 |
Ejemplo: Validación con allowlist vs blocklist#
# ❌ MAL: blocklist (intentar bloquear lo malo es interminable)
def validar_nombre(nombre: str) -> bool:
palabras_prohibidas = ['<script>', 'DROP', 'SELECT', '--', ';']
for palabra in palabras_prohibidas:
if palabra.lower() in nombre.lower():
return False
return True
# Un atacante siempre puede encontrar una variante que no esté en la lista
# ✅ BIEN: allowlist (solo permitir lo que sabemos que es válido)
import re
def validar_nombre(nombre: str) -> bool:
# Solo letras (incluyendo acentos y ñ), espacios y guiones. Máx 100 chars.
patron = r'^[a-zA-ZáéíóúÁÉÍÓÚñÑüÜ\s\-]{1,100}$'
return bool(re.match(patron, nombre))
print(validar_nombre("María José")) # True ✅
print(validar_nombre("<script>alert")) # False ✅
print(validar_nombre("O'Brien")) # False (apostrofe no permitido)
# Si O'Brien es un caso de uso real, agrégalo al patrón explícitamenteEjemplo: Validación en servidor (nunca solo en cliente)#
// ❌ MAL: solo validar en el cliente
// El atacante puede enviar requests directamente con Postman/curl y saltar esto
document.getElementById('form').addEventListener('submit', (e) => {
const edad = parseInt(document.getElementById('edad').value);
if (edad < 18) {
e.preventDefault();
alert('Debes ser mayor de edad');
}
// Si el JS es deshabilitado o saltado, no hay protección
});
// ✅ BIEN: validar SIEMPRE en el servidor (adicionalmente al cliente si quieres)
// --- servidor (Node.js/Express) ---
app.post('/registro', (req, res) => {
const { edad, email, nombre } = req.body;
const errores = [];
if (!Number.isInteger(edad) || edad < 18 || edad > 120) {
errores.push('Edad inválida: debe ser un número entre 18 y 120');
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errores.push('Email con formato inválido');
}
if (!/^[a-zA-ZáéíóúñÑ\s]{2,100}$/.test(nombre)) {
errores.push('Nombre inválido');
}
if (errores.length > 0) {
return res.status(400).json({ errores });
}
// Proceder con el registro
});Ejemplo: Validación de consistencia entre campos#
from datetime import date
from pydantic import BaseModel, validator
class Reservacion(BaseModel):
fecha_entrada: date
fecha_salida: date
habitaciones: int
huespedes: int
@validator('fecha_salida')
def salida_posterior_a_entrada(cls, v, values):
if 'fecha_entrada' in values and v <= values['fecha_entrada']:
raise ValueError('La fecha de salida debe ser posterior a la de entrada')
return v
@validator('fecha_entrada')
def no_en_pasado(cls, v):
if v < date.today():
raise ValueError('No se puede reservar en el pasado')
return v
@validator('huespedes')
def huespedes_vs_habitaciones(cls, v, values):
if 'habitaciones' in values:
max_huespedes = values['habitaciones'] * 4 # máximo 4 por habitación
if v > max_huespedes:
raise ValueError(f'Máximo {max_huespedes} huéspedes para {values["habitaciones"]} habitaciones')
return v
# Uso
try:
reservacion = Reservacion(
fecha_entrada=date(2025, 3, 10),
fecha_salida=date(2025, 3, 8), # ← Error: salida antes de entrada
habitaciones=2,
huespedes=3
)
except ValueError as e:
print(e) # "La fecha de salida debe ser posterior a la de entrada"V2.3 · Seguridad de Lógica de Negocio#
Esta sección cubre ataques donde el problema no es el dato en sí, sino cómo se usa el flujo de la aplicación. Un atacante puede intentar saltarse pasos, repetir acciones o manipular el estado de la aplicación.
| # | Requisito | Nivel |
|---|---|---|
| 2.3.1 | Los flujos de negocio deben procesarse en el orden correcto, sin poder saltarse pasos. | 1 |
| 2.3.2 | Implementar los límites de lógica de negocio según la documentación. | 2 |
| 2.3.3 | Usar transacciones para que una operación de negocio tenga éxito completo o falle por completo (sin estados intermedios). | 2 |
| 2.3.4 | Usar bloqueos a nivel de lógica de negocio para recursos limitados (evitar doble reserva). | 2 |
| 2.3.5 | Los flujos de alto valor requieren aprobación de múltiples usuarios. | 3 |
Ejemplo: Flujo secuencial (no saltarse pasos)#
# Escenario: proceso de compra en e-commerce
# El atacante intenta ir directamente al paso 3 sin pagar
from enum import Enum
class PasoCompra(Enum):
CARRITO = 1
DATOS_ENVIO = 2
PAGO = 3
CONFIRMACION = 4
# ❌ MAL: confiar en el frontend para indicar el paso actual
@app.route('/confirmar-orden', methods=['POST'])
def confirmar_orden():
orden_id = request.json['orden_id']
# Procesar confirmación sin verificar si ya se pagó
crear_orden(orden_id) # ← El atacante obtiene su orden gratis
# ✅ BIEN: verificar el estado en el servidor antes de avanzar
@app.route('/confirmar-orden', methods=['POST'])
def confirmar_orden():
orden_id = request.json['orden_id']
orden = db.get_orden(orden_id)
# Verificar que el paso anterior (pago) fue completado
if orden.estado != PasoCompra.PAGO:
return jsonify({'error': 'Debe completar el pago antes de confirmar'}), 400
if not orden.pago_verificado:
return jsonify({'error': 'El pago no ha sido verificado'}), 400
# Solo si todo está en orden, avanzar
orden.estado = PasoCompra.CONFIRMACION
db.save(orden)
return jsonify({'mensaje': 'Orden confirmada exitosamente'})Ejemplo: Transacciones para consistencia#
# Escenario: transferencia bancaria entre cuentas
# Si falla a la mitad, no queremos que el dinero desaparezca
# ❌ MAL: dos operaciones independientes, si falla la segunda el dinero se pierde
def transferir_mal(origen_id, destino_id, monto):
cuenta_origen = db.get_cuenta(origen_id)
cuenta_origen.saldo -= monto
db.save(cuenta_origen) # ← Si el servidor falla aquí...
cuenta_destino = db.get_cuenta(destino_id)
cuenta_destino.saldo += monto # ← ...esto nunca se ejecuta
db.save(cuenta_destino)
# ✅ BIEN: usar transacción (todo o nada)
from sqlalchemy.orm import Session
def transferir_bien(db: Session, origen_id: int, destino_id: int, monto: float):
try:
cuenta_origen = db.query(Cuenta).filter_by(id=origen_id).with_for_update().first()
cuenta_destino = db.query(Cuenta).filter_by(id=destino_id).with_for_update().first()
if cuenta_origen.saldo < monto:
raise ValueError("Saldo insuficiente")
cuenta_origen.saldo -= monto
cuenta_destino.saldo += monto
db.commit() # Solo se confirma si ambas operaciones tuvieron éxito
return True
except Exception as e:
db.rollback() # Si algo falla, se revierten TODOS los cambios
raise eEjemplo: Evitar doble reserva (race condition)#
# Escenario: dos usuarios intentan comprar el último asiento al mismo tiempo
# ❌ MAL: verificar disponibilidad y reservar en pasos separados
def reservar_asiento_mal(asiento_id, usuario_id):
asiento = db.get_asiento(asiento_id)
if asiento.disponible: # ← Dos usuarios pasan esta verificación
asiento.disponible = False # simultáneamente y ambos reservan
asiento.usuario_id = usuario_id
db.save(asiento)
return True
return False
# ✅ BIEN: usar bloqueo pesimista (SELECT FOR UPDATE) o transacción con check
def reservar_asiento_bien(db: Session, asiento_id: int, usuario_id: int):
with db.begin():
# WITH FOR UPDATE bloquea la fila hasta que terminemos
asiento = db.query(Asiento)\
.filter_by(id=asiento_id, disponible=True)\
.with_for_update()\
.first()
if not asiento:
return {'exito': False, 'mensaje': 'Asiento no disponible'}
asiento.disponible = False
asiento.usuario_id = usuario_id
# El commit al salir del bloque libera el bloqueo
return {'exito': True, 'mensaje': 'Asiento reservado'}V2.4 · Anti-automatización#
Algunos ataques no explotan vulnerabilidades de código: simplemente hacen la misma acción miles de veces por segundo. Los controles anti-automatización previenen esto.
| # | Requisito | Nivel |
|---|---|---|
| 2.4.1 | Implementar controles anti-automatización contra llamadas excesivas que puedan causar exfiltración de datos, spam, agotamiento de cuota, DDoS o abuso de recursos costosos. | 2 |
| 2.4.2 | Los flujos de negocio deben requerir tiempos realistas de interacción humana. | 3 |
Ejemplo: Rate limiting por usuario y global#
# Usando Flask-Limiter
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(
app,
key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"]
)
@app.route('/login', methods=['POST'])
@limiter.limit("5 per minute") # Máx 5 intentos de login por minuto por IP
def login():
...
@app.route('/api/transferencia', methods=['POST'])
@limiter.limit("10 per hour;3 per minute") # Límite por tiempo y volumen
def transferencia():
...
@app.route('/api/exportar-datos', methods=['GET'])
@limiter.limit("1 per day") # Exportaciones: una por día
def exportar_datos():
...Ejemplo: Verificar tiempo mínimo de interacción humana#
import time
from datetime import datetime, timedelta
# Escenario: formulario de registro
# Un bot puede completarlo en milisegundos; un humano tarda varios segundos
@app.route('/registro', methods=['POST'])
def registro():
tiempo_inicio = session.get('formulario_inicio')
tiempo_actual = datetime.now()
if tiempo_inicio:
tiempo_transcurrido = tiempo_actual - datetime.fromisoformat(tiempo_inicio)
# Un humano real tarda al menos 3 segundos en llenar el formulario
if tiempo_transcurrido < timedelta(seconds=3):
return jsonify({'error': 'Interacción demasiado rápida'}), 429
# También verificar que no tardó demasiado (token expirado)
if tiempo_transcurrido > timedelta(minutes=30):
return jsonify({'error': 'Formulario expirado, recarga la página'}), 400
# Procesar registro...
@app.route('/registro', methods=['GET'])
def mostrar_formulario():
session['formulario_inicio'] = datetime.now().isoformat()
return render_template('registro.html')**Complementa con CAPTCHA:** Los controles de tiempo y rate limiting son buenos, pero para acciones críticas (registro, login, recuperación de contraseña) considera agregar también un CAPTCHA como defensa en profundidad.
Resumen: ¿Qué cubre cada subsección?#
| Sección | Pregunta que responde | Técnica principal |
|---|---|---|
| V2.1 | ¿Están documentadas las reglas? | Documentación de reglas de negocio |
| V2.2 | ¿Los datos tienen sentido? | Allowlists, validación de formato y consistencia |
| V2.3 | ¿Se siguió el flujo correcto? | Máquinas de estado, transacciones, bloqueos |
| V2.4 | ¿Es una acción humana real? | Rate limiting, verificación de tiempos |
**Regla de oro de V2:** Nunca confíes en que el cliente siguió el flujo que tú diseñaste. **Siempre verifica el estado en el servidor** antes de ejecutar cualquier acción de negocio importante.