Skip to content

Validación y sanitización ​

Casi todas las vulnerabilidades empiezan con un dato que entró sin control. En esta lección aprenderás la diferencia entre validar, sanitizar y escapar, cuándo usar cada una y cómo construir una validación sólida en PHP y en Node con Zod.

Concepto ​

Piensa en el guardia de seguridad de un edificio:

  • Validar es revisar la identificación: "¿eres empleado? ¿tu credencial es válida?". Si no, no entras. Rechazas datos que no cumplen las reglas.
  • Sanitizar es pasar por el detector de metales y quitarte las llaves: entras, pero modificado. Transformas el dato para dejarlo en un formato aceptable (quitar espacios, quitar etiquetas HTML).
  • Escapar es cómo te presentan en cada sala: depende del contexto. Lo haces al usar el dato (en HTML, en SQL, en una URL...).

La regla de oro: valida en la entrada, escapa en la salida. Sanitiza solo cuando tenga sentido para el negocio (por ejemplo, normalizar un teléfono).

Lista blanca vs. lista negra ​

  • Lista negra: "rechaza si contiene <script>, DROP o ../". Siempre habrá una variante que olvidaste. ❌
  • Lista blanca: "acepta solo si son 5 dígitos" o "solo si es 'pequeño', 'mediano' o 'grande'". Todo lo demás se rechaza. ✅

¿Por qué validar en el servidor si ya lo hago en el navegador? ​

Los atributos required, type="email" o maxlength de HTML y tu validación en JavaScript se saltan en segundos desde las herramientas de desarrollo o con curl. La validación del navegador mejora la experiencia; la del servidor da la seguridad. Necesitas ambas.

Ejemplo: validación en PHP con filter_var ​

PHP trae filtros listos para los tipos más comunes:

php
<?php
// Números enteros con rango
$edad = filter_var($_POST['edad'] ?? '', FILTER_VALIDATE_INT, [
    'options' => ['min_range' => 18, 'max_range' => 120],
]);
// Devuelve el entero o false si no es válido

// Decimales
$precio = filter_var($_POST['precio'] ?? '', FILTER_VALIDATE_FLOAT);

// Correo electrónico
$email = filter_var(trim($_POST['email'] ?? ''), FILTER_VALIDATE_EMAIL);

// URL (y además comprobamos el esquema)
$web = filter_var($_POST['web'] ?? '', FILTER_VALIDATE_URL);
if ($web && !in_array(parse_url($web, PHP_URL_SCHEME), ['http', 'https'], true)) {
    $web = false;
}

// Booleano (acepta "1", "true", "on", "yes")
$acepta = filter_var($_POST['terminos'] ?? false, FILTER_VALIDATE_BOOLEAN);

// IP
$ip = filter_var($_POST['ip'] ?? '', FILTER_VALIDATE_IP);

Error común

Comprobar el resultado con if (!$edad). Si el usuario envía 0 (válido en algunos campos), !0 es true y lo rechazas por error. Compara siempre con === false.

Ejemplo: un validador reutilizable en PHP ​

Para formularios reales conviene una pequeña clase que acumule los errores:

php
<?php
declare(strict_types=1);

final class Validador
{
    private array $errores = [];
    private array $limpios = [];

    public function __construct(private array $datos) {}

    // Obtiene el valor como string recortado (o null si no existe o no es string)
    private function valor(string $campo): ?string
    {
        $v = $this->datos[$campo] ?? null;
        return is_string($v) ? trim($v) : null;
    }

    public function requerido(string $campo, string $etiqueta): self
    {
        if ($this->valor($campo) === null || $this->valor($campo) === '') {
            $this->errores[$campo] = "$etiqueta es obligatorio.";
        }
        return $this;
    }

    public function texto(string $campo, string $etiqueta, int $min, int $max): self
    {
        $v = $this->valor($campo) ?? '';
        $largo = mb_strlen($v);
        if (!isset($this->errores[$campo]) && ($largo < $min || $largo > $max)) {
            $this->errores[$campo] = "$etiqueta debe tener entre $min y $max caracteres.";
        }
        $this->limpios[$campo] = $v;
        return $this;
    }

    public function email(string $campo): self
    {
        $v = filter_var($this->valor($campo) ?? '', FILTER_VALIDATE_EMAIL);
        if ($v === false && !isset($this->errores[$campo])) {
            $this->errores[$campo] = 'El correo no es válido.';
        }
        $this->limpios[$campo] = $v ?: null;
        return $this;
    }

    public function entero(string $campo, string $etiqueta, int $min, int $max): self
    {
        $v = filter_var($this->valor($campo) ?? '', FILTER_VALIDATE_INT, [
            'options' => ['min_range' => $min, 'max_range' => $max],
        ]);
        if ($v === false && !isset($this->errores[$campo])) {
            $this->errores[$campo] = "$etiqueta debe ser un número entre $min y $max.";
        }
        $this->limpios[$campo] = $v === false ? null : $v;
        return $this;
    }

    // Lista blanca de valores permitidos
    public function enLista(string $campo, string $etiqueta, array $permitidos): self
    {
        $v = $this->valor($campo);
        if (!in_array($v, $permitidos, true) && !isset($this->errores[$campo])) {
            $this->errores[$campo] = "$etiqueta no es una opción válida.";
        }
        $this->limpios[$campo] = $v;
        return $this;
    }

    public function regex(string $campo, string $etiqueta, string $patron): self
    {
        $v = $this->valor($campo) ?? '';
        if (!preg_match($patron, $v) && !isset($this->errores[$campo])) {
            $this->errores[$campo] = "$etiqueta tiene un formato incorrecto.";
        }
        $this->limpios[$campo] = $v;
        return $this;
    }

    public function falla(): bool { return $this->errores !== []; }
    public function errores(): array { return $this->errores; }
    public function limpios(): array { return $this->limpios; }
}
php
<?php
require 'Validador.php';

$v = (new Validador($_POST))
    ->requerido('nombre', 'El nombre')->texto('nombre', 'El nombre', 2, 80)
    ->requerido('email', 'El correo')->email('email')
    ->entero('cantidad', 'La cantidad', 1, 50)
    ->enLista('talla', 'La talla', ['CH', 'M', 'G', 'XG'])
    ->regex('cp', 'El código postal', '/^\d{5}$/');

if ($v->falla()) {
    http_response_code(422);
    $errores = $v->errores();
    // vuelve a mostrar el formulario con los errores (escapados con e())
} else {
    $datos = $v->limpios();
    // ✅ $datos ya está validado; guárdalo con consultas preparadas
}

Ejemplo: validación en Node con Zod ​

Zod es la librería más usada en el ecosistema JavaScript/TypeScript. Defines un esquema y Zod valida, convierte tipos y te devuelve errores claros.

bash
npm install zod
js
import { z } from 'zod';

export const pedidoSchema = z.object({
  nombre: z.string().trim().min(2, 'Mínimo 2 caracteres').max(80),
  email: z.string().trim().toLowerCase().email('Correo inválido'),
  // coerce convierte "3" (string de formulario) a número 3
  cantidad: z.coerce.number().int().min(1).max(50),
  talla: z.enum(['CH', 'M', 'G', 'XG']),             // lista blanca
  cp: z.string().regex(/^\d{5}$/, 'Código postal de 5 dígitos'),
  notas: z.string().max(500).optional().default(''),
}).strict(); // ❗ rechaza campos extra (evita "mass assignment")
js
import { pedidoSchema } from './esquemas.js';

// Middleware genérico de validación
const validar = schema => (req, res, next) => {
  const resultado = schema.safeParse(req.body);
  if (!resultado.success) {
    return res.status(422).json({
      errores: resultado.error.flatten().fieldErrors,
    });
  }
  req.datos = resultado.data; // datos limpios y con tipos correctos
  next();
};

app.post('/pedidos', validar(pedidoSchema), async (req, res) => {
  const { nombre, email, cantidad, talla, cp, notas } = req.datos;
  await pool.execute(
    'INSERT INTO pedidos (nombre, email, cantidad, talla, cp, notas) VALUES (?, ?, ?, ?, ?, ?)',
    [nombre, email, cantidad, talla, cp, notas]
  );
  res.status(201).json({ ok: true });
});

Mass assignment: el campo que no esperabas ​

Si guardas req.body o $_POST completos en la base de datos, un atacante puede agregar campos que no estaban en tu formulario:

bash
# El formulario de perfil solo tiene "nombre", pero el atacante añade "rol"
curl -X POST https://tusitio.com/perfil -d "nombre=Ana" -d "rol=admin"
js
// ❌ Vulnerable: actualiza cualquier campo que llegue
await Usuario.update(req.body, { where: { id: req.session.usuarioId } });

// ✅ Solo los campos permitidos (o un esquema Zod con .strict()/.pick())
const { nombre, telefono } = req.body;
await Usuario.update({ nombre, telefono }, { where: { id: req.session.usuarioId } });

En Laravel esto se controla con $fillable en el modelo.

Sanitización: cuándo sí ​

php
<?php
// Normalizar datos para el negocio (no es una defensa contra ataques)
$telefono = preg_replace('/\D+/', '', $_POST['telefono'] ?? ''); // solo dígitos
$email    = mb_strtolower(trim($_POST['email'] ?? ''));
$slug     = trim(preg_replace('/[^a-z0-9]+/', '-', mb_strtolower($titulo)), '-');

// Quitar etiquetas de un campo que debe ser texto plano
$nombre = strip_tags($_POST['nombre'] ?? '');

Error común

Usar strip_tags() o el filtro obsoleto FILTER_SANITIZE_STRING (eliminado en PHP 8.1) como "protección contra XSS". No sustituye a escapar la salida con htmlspecialchars. Escapar en la salida siempre.

Escapar según el destino ​

Destino del datoHerramienta
HTMLhtmlspecialchars() / motor de plantillas / textContent
SQLConsultas preparadas
Comando del sistemaescapeshellarg() / execFile con array
URL (parámetro)urlencode() / encodeURIComponent()
JavaScript embebidojson_encode() con banderas JSON_HEX_*
Ruta de archivobasename() + lista blanca + realpath()

Path traversal (recorrido de directorios) ​

php
<?php
// descargar.php?archivo=../../config/.env  ← intento de leer archivos fuera de la carpeta
$base = realpath(__DIR__ . '/documentos');
$ruta = realpath($base . '/' . basename($_GET['archivo'] ?? ''));

// ✅ Verificamos que la ruta final siga dentro de la carpeta permitida
if ($ruta === false || !str_starts_with($ruta, $base . DIRECTORY_SEPARATOR)) {
    http_response_code(404);
    exit('Archivo no encontrado');
}
readfile($ruta);

Buena práctica

Valida también los límites: longitud máxima de cada texto, tamaño máximo del cuerpo de la petición (express.json({ limit: '100kb' }), post_max_size en PHP) y número máximo de elementos en arrays. Evitas que alguien sature tu servidor con datos gigantes.

Ejercicios ​

  1. Valida en PHP un formulario de contacto con: nombre (2-60), correo, asunto (uno de ventas, soporte, otro) y mensaje (10-2000).
  2. Crea un esquema Zod para registrar un producto: nombre, precio positivo con máximo 2 decimales, stock entero ≥ 0 y categoría de una lista.
  3. ¿Qué problema hay en if (in_array($_GET['dir'], ['ASC', 'DESC']))? (Pista: comparación débil.)
Ver solución
  1. Con la clase Validador:
php
<?php
$v = (new Validador($_POST))
    ->requerido('nombre', 'El nombre')->texto('nombre', 'El nombre', 2, 60)
    ->requerido('email', 'El correo')->email('email')
    ->enLista('asunto', 'El asunto', ['ventas', 'soporte', 'otro'])
    ->requerido('mensaje', 'El mensaje')->texto('mensaje', 'El mensaje', 10, 2000);

if ($v->falla()) {
    $errores = $v->errores();
} else {
    // enviar correo con $v->limpios()
}
  1. Esquema:
js
const productoSchema = z.object({
  nombre: z.string().trim().min(2).max(120),
  precio: z.coerce.number().positive().multipleOf(0.01),
  stock: z.coerce.number().int().min(0),
  categoria: z.enum(['ropa', 'calzado', 'accesorios']),
}).strict();
  1. Sin el tercer argumento true, in_array compara de forma débil (==). Si $_GET['dir'] llega como array, in_array lanzaría error o comportamientos raros, y en otras listas con números "1abc" == 1 puede dar true en versiones antiguas. Usa siempre in_array($valor, $lista, true) y verifica que sea string.

Resumen ​

  • Valida en la entrada (servidor), escapa en la salida, sanitiza solo para normalizar.
  • Usa listas blancas, no listas negras.
  • En PHP: filter_var con === false; en Node: Zod con safeParse y .strict().
  • Evita mass assignment: nunca guardes $_POST o req.body completos.
  • Protege rutas de archivos con basename y realpath.
  • Limita tamaños y longitudes de todo lo que recibes.

Academia Web — aprende, copia y construye.