Tema
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>,DROPo../". 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 zodjs
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 dato | Herramienta |
|---|---|
| HTML | htmlspecialchars() / motor de plantillas / textContent |
| SQL | Consultas preparadas |
| Comando del sistema | escapeshellarg() / execFile con array |
| URL (parámetro) | urlencode() / encodeURIComponent() |
| JavaScript embebido | json_encode() con banderas JSON_HEX_* |
| Ruta de archivo | basename() + 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
- Valida en PHP un formulario de contacto con: nombre (2-60), correo, asunto (uno de
ventas,soporte,otro) y mensaje (10-2000). - 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.
- ¿Qué problema hay en
if (in_array($_GET['dir'], ['ASC', 'DESC']))? (Pista: comparación débil.)
Ver solución
- 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()
}- 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();- Sin el tercer argumento
true,in_arraycompara de forma débil (==). Si$_GET['dir']llega como array,in_arraylanzaría error o comportamientos raros, y en otras listas con números"1abc" == 1puede dartrueen versiones antiguas. Usa siemprein_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_varcon=== false; en Node: Zod consafeParsey.strict(). - Evita mass assignment: nunca guardes
$_POSToreq.bodycompletos. - Protege rutas de archivos con
basenameyrealpath. - Limita tamaños y longitudes de todo lo que recibes.