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.

#RequisitoNivel
2.1.1Documentar las reglas de validación para cada tipo de dato esperado.1
2.1.2Documentar cómo se valida la consistencia lógica entre datos relacionados (ej: código postal y ciudad).2
2.1.3Documentar 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: 99

V2.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.

#RequisitoNivel
2.2.1Validar 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.2Implementar validación de input en el servidor (la validación del cliente es solo UX).1
2.2.3Verificar 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ícitamente

Ejemplo: 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.

#RequisitoNivel
2.3.1Los flujos de negocio deben procesarse en el orden correcto, sin poder saltarse pasos.1
2.3.2Implementar los límites de lógica de negocio según la documentación.2
2.3.3Usar transacciones para que una operación de negocio tenga éxito completo o falle por completo (sin estados intermedios).2
2.3.4Usar bloqueos a nivel de lógica de negocio para recursos limitados (evitar doble reserva).2
2.3.5Los 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 e

Ejemplo: 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.

#RequisitoNivel
2.4.1Implementar 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.2Los 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ónPregunta que respondeTé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.