Skip to content

Principios y OWASP Top 10 ​

Antes de aprender a bloquear ataques concretos necesitas una forma de pensar. En esta lección verás los principios que usan los profesionales de seguridad y el OWASP Top 10, la lista de los riesgos más críticos en aplicaciones web. Es el mapa que seguiremos en todo el curso.

Concepto ​

Imagina que tu aplicación es una casa. Puedes poner la mejor cerradura del mundo en la puerta principal, pero si dejas una ventana abierta, el ladrón entra por ahí. Un atacante no ataca donde eres fuerte: busca el eslabón más débil.

Por eso la seguridad se basa en principios generales, no en trucos sueltos:

1. Nunca confíes en la entrada del usuario ​

Todo lo que viene del navegador puede ser manipulado: campos de formulario, parámetros de la URL, cookies, cabeceras HTTP, archivos subidos e incluso los campos ocultos (type="hidden") o los <select>. La validación en JavaScript del navegador es para la comodidad del usuario; la validación de verdad ocurre en el servidor.

bash
# Cualquiera puede enviar lo que quiera a tu servidor sin usar tu formulario
curl -X POST https://tusitio.com/comprar.php \
  -d "producto_id=5" \
  -d "precio=0.01"     # ¡el precio nunca debió venir del cliente!

2. Defensa en profundidad ​

No dependas de una sola barrera. Si una falla, la siguiente debe detener el ataque. Ejemplo para evitar XSS: validas la entrada, escapas la salida y además configuras una cabecera CSP. Tres capas.

3. Mínimo privilegio ​

Cada usuario, proceso o conexión debe tener solo los permisos que necesita. El usuario de MySQL que usa tu web no necesita permiso para borrar tablas ni crear usuarios. Un editor de blog no necesita acceso al panel de facturación.

4. Fallar de forma segura ​

Cuando algo sale mal, el sistema debe quedar en un estado seguro. Si la verificación de permisos lanza un error, la respuesta correcta es denegar el acceso, no permitirlo. Y nunca muestres errores técnicos (rutas, consultas SQL, trazas) al usuario final.

5. Seguridad por defecto ​

La configuración inicial debe ser la más segura. Las cuentas nuevas no deberían tener rol de administrador, las cookies deberían ser HttpOnly desde el principio, el modo debug apagado en producción.

6. No reinventes la criptografía ​

Usa funciones probadas (password_hash, random_bytes, bcrypt, librerías oficiales). Inventar tu propio "cifrado" con base64 o md5 es un error clásico.

OWASP Top 10 (2021) ​

OWASP (Open Worldwide Application Security Project) es una fundación sin fines de lucro que publica guías gratuitas. Su Top 10 se actualiza cada pocos años con datos reales de miles de aplicaciones.

#RiesgoEn palabras simplesLección
A01Control de acceso rotoUn usuario puede ver o modificar lo que no le correspondeEsta lección
A02Fallas criptográficasDatos sensibles sin cifrar, contraseñas con MD5, sin HTTPS05, 07
A03InyecciónSQL, comandos del sistema, XSS02, 03
A04Diseño inseguroFallas de lógica: sin límite de intentos, flujos manipulables05
A05Configuración incorrectaDebug activado, listados de carpetas, cabeceras faltantes07, 12
A06Componentes vulnerablesLibrerías o CMS desactualizados12
A07Fallas de autenticaciónSesiones robables, contraseñas débiles05, 06
A08Integridad de software y datosDependencias o actualizaciones no verificadas, deserialización insegura11
A09Registro y monitoreo insuficienteTe atacan y nadie se entera12
A10SSRFEl servidor hace peticiones a URLs que controla el atacanteEsta lección

Ejemplo: control de acceso roto (A01) ​

Es el riesgo número uno. El caso típico se llama IDOR (Insecure Direct Object Reference): la aplicación usa un ID de la URL sin comprobar que el recurso pertenece al usuario.

php
<?php
session_start();
require 'db.php'; // crea $pdo

// ❌ El usuario solo cambia ?id=101 por ?id=102 y ve la factura de otra persona
$id = (int) ($_GET['id'] ?? 0);

$stmt = $pdo->prepare('SELECT * FROM facturas WHERE id = ?');
$stmt->execute([$id]);
$factura = $stmt->fetch();

echo json_encode($factura);
php
<?php
session_start();
require 'db.php';

// 1. ¿Hay sesión iniciada?
if (!isset($_SESSION['usuario_id'])) {
    http_response_code(401);
    exit('Debes iniciar sesión');
}

$id = (int) ($_GET['id'] ?? 0);

// 2. ✅ Filtramos también por el dueño: solo devuelve SUS facturas
$stmt = $pdo->prepare('SELECT * FROM facturas WHERE id = ? AND usuario_id = ?');
$stmt->execute([$id, $_SESSION['usuario_id']]);
$factura = $stmt->fetch();

// 3. Si no existe o no es suya, respondemos igual (no revelamos cuál de las dos)
if (!$factura) {
    http_response_code(404);
    exit('Factura no encontrada');
}

header('Content-Type: application/json');
echo json_encode($factura);
js
// Middleware que exige sesión iniciada
function requiereLogin(req, res, next) {
  if (!req.session.usuarioId) {
    return res.status(401).json({ error: 'Debes iniciar sesión' });
  }
  next();
}

app.get('/facturas/:id', requiereLogin, async (req, res) => {
  const id = Number.parseInt(req.params.id, 10);

  // ✅ La consulta incluye al dueño: imposible ver facturas ajenas
  const [filas] = await pool.execute(
    'SELECT * FROM facturas WHERE id = ? AND usuario_id = ?',
    [id, req.session.usuarioId]
  );

  if (filas.length === 0) {
    return res.status(404).json({ error: 'Factura no encontrada' });
  }
  res.json(filas[0]);
});

Error común

Ocultar el botón "Eliminar" en la interfaz para los usuarios que no son administradores no es control de acceso. Cualquiera puede enviar la petición a mano. La verificación de permisos siempre va en el servidor, en cada ruta.

Ejemplo: SSRF (A10) ​

Si tu aplicación descarga una imagen desde una URL que escribe el usuario ("importar avatar desde URL"), un atacante puede pedir http://localhost/admin o http://169.254.169.254/ (los metadatos de servidores en la nube, que a veces contienen credenciales).

php
<?php
// Protección básica contra SSRF: lista blanca de esquemas y bloqueo de IPs internas
function urlEsSegura(string $url): bool
{
    $partes = parse_url($url);
    if (!$partes || !in_array($partes['scheme'] ?? '', ['http', 'https'], true)) {
        return false; // nada de file://, gopher://, ftp://...
    }

    $ip = gethostbyname($partes['host'] ?? '');

    // FILTER_FLAG_NO_PRIV_RANGE y NO_RES_RANGE rechazan 10.x, 192.168.x, 127.x, 169.254.x...
    return filter_var(
        $ip,
        FILTER_VALIDATE_IP,
        FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
    ) !== false;
}

$url = $_POST['avatar_url'] ?? '';
if (!urlEsSegura($url)) {
    exit('URL no permitida');
}
// Aquí ya puedes descargarla (con tiempo límite y tamaño máximo)

Más allá de lo básico

Un atacante avanzado puede usar un dominio que resuelve a una IP pública al validar y a una privada al descargar (DNS rebinding). En proyectos críticos, descarga usando la IP ya validada o pasa por un proxy de salida restringido.

Casos de uso reales ​

  • Tienda en línea: el precio y el total siempre se calculan en el servidor a partir del ID del producto, nunca se aceptan desde el formulario.
  • Panel multiusuario: cada consulta lleva WHERE usuario_id = ? o WHERE empresa_id = ?.
  • Base de datos: el usuario de MySQL de la web tiene SELECT, INSERT, UPDATE, DELETE sobre su base y nada más.
sql
-- Mínimo privilegio en MySQL: un usuario solo para la app
CREATE USER 'app_tienda'@'localhost' IDENTIFIED BY 'una-contraseña-larga-y-aleatoria';
GRANT SELECT, INSERT, UPDATE, DELETE ON tienda.* TO 'app_tienda'@'localhost';
FLUSH PRIVILEGES;

Buena práctica

Haz un "modelo de amenazas" rápido al empezar cada proyecto: ¿qué datos valiosos hay? (contraseñas, tarjetas, datos personales), ¿quién podría querer atacarlos? y ¿por dónde entrarían? Diez minutos de reflexión te ahorran semanas de problemas.

Ejercicios ​

  1. Clasifica cada caso según el OWASP Top 10: (a) un sitio en WordPress con plugins sin actualizar desde 2019; (b) contraseñas guardadas con md5(); (c) un sitio que muestra la consulta SQL completa cuando falla; (d) cambiar /pedido/7 por /pedido/8 muestra el pedido de otro cliente.
  2. Escribe en Express una ruta DELETE /notas/:id que solo permita borrar notas propias.
  3. Enumera los permisos mínimos de MySQL para un blog que solo muestra artículos (sin panel de administración en el mismo servidor).
Ver solución
  1. (a) A06 Componentes vulnerables; (b) A02 Fallas criptográficas; (c) A05 Configuración incorrecta; (d) A01 Control de acceso roto (IDOR).

  2. Ruta segura:

js
app.delete('/notas/:id', requiereLogin, async (req, res) => {
  const id = Number.parseInt(req.params.id, 10);

  // Borramos solo si la nota es del usuario actual
  const [resultado] = await pool.execute(
    'DELETE FROM notas WHERE id = ? AND usuario_id = ?',
    [id, req.session.usuarioId]
  );

  if (resultado.affectedRows === 0) {
    return res.status(404).json({ error: 'Nota no encontrada' });
  }
  res.status(204).end();
});
  1. Solo SELECT:
sql
GRANT SELECT ON blog.* TO 'blog_lectura'@'localhost';

Resumen ​

  • Toda entrada del usuario es sospechosa: valida siempre en el servidor.
  • Aplica defensa en profundidad, mínimo privilegio, fallo seguro y seguridad por defecto.
  • El OWASP Top 10 es la lista de referencia de riesgos; el número uno es el control de acceso roto.
  • Cada consulta que devuelve datos privados debe filtrar por el dueño.
  • No inventes criptografía: usa funciones y librerías probadas.

Academia Web — aprende, copia y construye.