Sanitizar Input de Usuario
Cómo sanitizar y validar input de usuario en Python, Java y JavaScript para prevenir ataques de inyección.
Visión General
El input de usuario no confiable es la causa raíz de la mayoría de las vulnerabilidades en aplicaciones web: XSS, SQL injection, command injection, path traversal y header injection. La sanitización transforma input crudo en datos seguros y normalizados; la validación verifica que cumpla restricciones estructurales y semánticas. Esta receta cubre ambas capas en Python, JavaScript y Java — además de los casos avanzados (comandos del OS, rutas de archivo, NoSQL, uploads) que la mayoría de guías básicas omiten.
Cuándo Usar
Usa este recurso cuando:
- Aceptes datos de formularios, query parameters o JSON bodies de clientes web
- Proceses uploads de archivos o rutas de archivo proporcionadas por usuarios — consulta validación de uploads para la receta completa
- Renderices contenido generado por usuarios en HTML, email o logs — consulta prevención de XSS y escapar entidades HTML
- Pases valores de usuario a comandos del OS, queries SQL o filtros NoSQL — consulta prevención de SQL injection
Cuándo Evitar
- Sanitizar cuando puedes validar: si el input debería ser un entero, un enum o un UUID, recházalo en lugar de “limpiarlo” — no existe una versión segura de un ID mal formado.
- Sanitizar output que controlas: el encoding pertenece al límite de renderizado. No pre-escapes datos guardados en la base de datos, o terminarás con texto doble-escapado y pérdida de fidelidad.
- Escribir tu propio sanitizador: el escaping de HTML y shell tiene décadas de casos límite. Usa las librerías mantenidas de la tabla de Variantes.
Solución
Python
Los dos caballos de batalla son bleach para campos HTML y las queries parametrizadas para SQL — cubren la mayor parte de la superficie de inyección en un servicio Python:
# Sanitización HTML con bleach
# pip install bleach
import bleach
def sanitize_html(text: str) -> str:
allowed_tags = ['p', 'br', 'strong', 'em']
allowed_attrs = {}
return bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs, strip=True)
user_input = '<script>alert("xss")</script><p>Hello</p>'
print(sanitize_html(user_input))
# Output: '<p>Hello</p>'
# Parametrización SQL segura con psycopg2
import psycopg2
def get_user_by_email(email: str):
conn = psycopg2.connect("dbname=test")
cur = conn.cursor()
# Nunca uses f-strings o % formatting para SQL
cur.execute("SELECT * FROM users WHERE email = %s", (email,))
return cur.fetchone()
JavaScript
En el lado JS, DOMPurify maneja la sanitización HTML (funciona en browser y en Node.js vía jsdom), mientras express-validator añade una capa de validación directamente en la ruta:
// DOMPurify para sanitización HTML del lado del browser
// npm install dompurify jsdom (uso en Node.js)
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
const dirty = '<img src=x onerror=alert(1)><b>Hello</b>';
console.log(DOMPurify.sanitize(dirty, { ALLOWED_TAGS: ['b'] }));
// Output: '<b>Hello</b>'
// express-validator para validación de input de rutas
// npm install express-validator
import { body, validationResult } from 'express-validator';
app.post('/register',
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
// Seguro para continuar
}
);
Java
Los equivalentes en Java son JSoup con sus perfiles Safelist para HTML, y el OWASP Java Encoder cuando necesitas output encoding ajustado a un contexto específico:
// JSoup para sanitización HTML
// Maven: org.jsoup:jsoup
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.safety.Safelist;
public class HtmlSanitizer {
public static String sanitize(String input) {
return Jsoup.clean(input, Safelist.basic());
}
}
// OWASP Java Encoder para encoding específico por contexto
// Maven: org.owasp.encoder:encoder
import org.owasp.encoder.Encode;
public class SafeOutput {
public static void renderUserContent(String userInput) {
String safeForHtml = Encode.forHtml(userInput);
String safeForJs = Encode.forJavaScript(userInput);
String safeForCss = Encode.forCssString(userInput);
}
}
Explicación
La sanitización y la validación son capas complementarias, y el orden importa. La sanitización remueve o escapa constructos peligrosos; la validación rechaza datos que no coinciden con schemas, tipos o rangos esperados. Para un campo de email, primero validas el formato (rechazas lo mal formado) y luego escapas en HTML al renderizar si el valor se refleja en un template.
El bleach de Python es ideal para campos de rich-text porque permite una allow-list explícita de tags. DOMPurify (JS) y JSoup (Java) sirven el mismo propósito. Para SQL, las queries parametrizadas son el único enfoque seguro; la concatenación de strings es siempre vulnerable. Para output encoding, el contexto importa: HTML encoding, JavaScript encoding, CSS encoding y URL encoding tienen reglas diferentes y deben aplicarse en el contexto correcto — un string HTML-escaped sigue siendo inseguro dentro de un bloque <script>.
El pipeline completo se ve así:
Variantes
| Tecnología | Librería | Propósito | Notas |
|---|---|---|---|
| Python | bleach | Sanitización HTML | Basado en allow-list, mantenido por Mozilla |
| Python | psycopg2 / sqlalchemy | Parametrización SQL | Usa bound parameters, nunca format strings |
| JavaScript | DOMPurify | Sanitización HTML | Rápido, browser + Node.js, configurable |
| JavaScript | express-validator | Validación de input | Middleware para rutas Express |
| Java | JSoup | Sanitización HTML | Perfiles Safelist para casos de uso comunes |
| Java | OWASP Java Encoder | Encoding específico por contexto | HTML, JS, CSS, URL, attribute encoding |
Escenarios Avanzados
Prevención de command injection (Python)
Cuando pases input de usuario a comandos del OS, usa listas de argumentos en lugar de strings de shell. El ejemplo muestra dos capas: shell=False con un array de argumentos, más shlex.quote para el caso raro en que un string de shell es inevitable — siempre combinado con una allow-list de rutas:
import subprocess
import shlex
# VULNERABLE: shell=True con input de usuario
# subprocess.run(f"ls {user_dir}", shell=True) # Nunca hagas esto
# SEGURO: shell=False con lista de argumentos
def list_directory(directory: str) -> str:
"""Listar contenido de directorio de forma segura."""
# Validar que el directorio esté dentro de la base permitida
import os
allowed_base = '/var/uploads'
real_path = os.path.realpath(directory)
if not real_path.startswith(allowed_base):
raise ValueError('Directorio fuera de la base permitida')
result = subprocess.run(
['ls', '-la', real_path],
capture_output=True,
text=True,
timeout=10,
shell=False, # Crítico: nunca uses shell=True con input de usuario
)
return result.stdout
# SEGURO: shlex.quote si shell=True es inevitable (casos raros)
def grep_file(pattern: str, filename: str) -> str:
"""Grep con argumentos escapados."""
safe_pattern = shlex.quote(pattern)
safe_filename = shlex.quote(filename)
result = subprocess.run(
f'grep {safe_pattern} {safe_filename}',
capture_output=True,
text=True,
shell=True,
timeout=10,
)
return result.stdout
Prevención de path traversal (Node.js)
../../etc/passwd es el ataque clásico: normaliza el path y luego verifica que siga resolviendo dentro de tu directorio permitido. El check de null-byte atrapa un segundo bypass donde los atacantes terminan el string antes de tiempo:
const path = require('path');
const fs = require('fs').promises;
const ALLOWED_BASE = '/var/uploads';
async function readUserFile(userPath) {
// Resolver a path absoluto y verificar que permanezca dentro de la base permitida
const resolved = path.resolve(ALLOWED_BASE, userPath);
const normalized = path.normalize(resolved);
// Prevenir directory traversal: ../etc/passwd
if (!normalized.startsWith(ALLOWED_BASE + path.sep)) {
throw new Error('Path traversal detectado');
}
// Check adicional: asegurar que no haya null bytes
if (userPath.includes('\0')) {
throw new Error('Null byte en path');
}
try {
return await fs.readFile(normalized, 'utf-8');
} catch (err) {
if (err.code === 'ENOENT') {
throw new Error('Archivo no encontrado');
}
throw err;
}
}
// Uso
// readUserFile('../../etc/passwd') -> lanza "Path traversal detectado"
// readUserFile('reports/2024/q1.pdf') -> lee /var/uploads/reports/2024/q1.pdf
Prevención de NoSQL injection (JavaScript/MongoDB)
La inyección NoSQL recibe menos atención que la SQL pero sigue el mismo patrón — operadores como $ne o $gt introducidos en JSON bodies pueden saltear la autenticación. Quita toda clave que empiece con $:
const { MongoClient } = require('mongodb');
// VULNERABLE: input de usuario pasado directamente a query
// const user = await db.collection('users').findOne({
// $where: `this.username == '${req.body.username}'`
// });
// SEGURO: usar métodos de query del driver, nunca $where con input de usuario
async function findUser(username) {
// Validar shape del input primero
if (typeof username !== 'string' || username.length > 100) {
throw new Error('Username inválido');
}
const client = new MongoClient(process.env.MONGO_URL);
await client.connect();
const db = client.db('app');
// Usar queries estructuradas, no $where basado en strings
return await db.collection('users').findOne({ username });
}
// SEGURO: sanitizar operadores de query del input de usuario
function sanitizeQuery(obj) {
// Remover operadores $ de objetos de query suministrados por usuario
const cleaned = {};
for (const [key, value] of Object.entries(obj)) {
if (key.startsWith('$')) {
continue; // Strip $where, $gt, $ne, etc.
}
if (typeof value === 'object' && value !== null) {
cleaned[key] = sanitizeQuery(value);
} else {
cleaned[key] = value;
}
}
return cleaned;
}
// Uso: { username: { $ne: null } } se convierte en { username: {} } -> seguro
Validación de file uploads (Python)
Los uploads de archivos también son input — valida los magic bytes en lugar de la extensión, limita el tamaño, renombra a un ID aleatorio y almacena fuera del web root:
import os
import uuid
import magic
ALLOWED_MIME_TYPES = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'application/pdf': '.pdf',
}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
def validate_and_save_upload(file_bytes: bytes, original_name: str) -> str:
"""Validar y guardar de forma segura un archivo subido."""
# Verificar tamaño de archivo
if len(file_bytes) > MAX_FILE_SIZE:
raise ValueError(f'Archivo excede límite de {MAX_FILE_SIZE // 1024 // 1024}MB')
# Detectar MIME type real desde magic bytes, no extensión
mime = magic.from_buffer(file_bytes, mime=True)
if mime not in ALLOWED_MIME_TYPES:
raise ValueError(f'Tipo de archivo {mime} no permitido')
# Generar nombre aleatorio, preservar extensión segura
ext = ALLOWED_MIME_TYPES[mime]
safe_name = f'{uuid.uuid4().hex}{ext}'
# Almacenar fuera del web root
upload_dir = '/var/uploads'
safe_path = os.path.join(upload_dir, safe_name)
# Verificar que el path no escape del directorio de uploads
if not os.path.realpath(safe_path).startswith(upload_dir):
raise ValueError('Path de archivo inválido')
with open(safe_path, 'wb') as f:
f.write(file_bytes)
return safe_name
Validación de file uploads (Java)
El mismo enfoque en Java — inspecciona los bytes de firma y luego escribe bajo un nombre generado:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
public class UploadValidator {
private static final long MAX_SIZE = 5L * 1024 * 1024; // 5 MB
private static final Path UPLOAD_DIR = Path.of("/var/uploads");
private static final Map<String, byte[]> MAGIC = Map.of(
".jpg", new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF},
".png", new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47},
".pdf", new byte[]{0x25, 0x50, 0x44, 0x46} // "%PDF"
);
public static String save(byte[] data) throws IOException {
if (data.length > MAX_SIZE) {
throw new IllegalArgumentException("File exceeds 5MB limit");
}
String ext = null;
for (var entry : MAGIC.entrySet()) {
byte[] sig = entry.getValue();
boolean match = data.length >= sig.length;
for (int i = 0; match && i < sig.length; i++) {
match = data[i] == sig[i];
}
if (match) { ext = entry.getKey(); break; }
}
if (ext == null) {
throw new IllegalArgumentException("File type not allowed");
}
Path target = UPLOAD_DIR.resolve(UUID.randomUUID() + ext).normalize();
if (!target.startsWith(UPLOAD_DIR)) {
throw new IllegalArgumentException("Invalid file path");
}
Files.write(target, data);
return target.getFileName().toString();
}
}
Código companion: el repo companion de sanitize-user-input tiene el guard de path traversal y el sanitizador NoSQL como scripts Node ejecutables, más un ejemplo de validación en Python.
Lo que funciona
- Valida primero, luego sanitiza: rechaza input inválido temprano; la sanitización es red de seguridad, no guardián
- Usa allow-lists, no block-lists: define lo que se permite (tags, protocolos, caracteres) en lugar de intentar bloquear cada vector de ataque
- Queries parametrizadas para todo SQL: los prepared statements eliminan SQL injection sin importar el contenido del input
- Encoding context-aware: usa HTML encoding en HTML, JS encoding en bloques
<script>, CSS encoding en atributos style - Rate-limit y size-limit: limita tamaño de body y rate de requests para prevenir ataques ReDoS y agotamiento de memoria
Errores Comunes
- Black-listing de tags HTML: los atacantes inventan nuevos tags y atributos; las allow-lists son el único enfoque confiable
- Sanitizar después de validar: la validación debe ocurrir sobre input crudo; sanitizar primero puede saltear reglas de validación
- Usar regex para parsear HTML: regex no puede parsear HTML correctamente; siempre usa un parser HTML apropiado para sanitización
- Encoding una vez y reutilizar en todos lados: output HTML-escaped es inseguro dentro de strings JavaScript; codifica por contexto
- Confiar en validación del lado del cliente: los checks del cliente mejoran UX pero son trivialmente bypassables; siempre re-valida del lado del servidor
Ver También
- OWASP Input Validation Cheat Sheet: diseño de allow-lists, validación semántica y reglas de canonicalización.
- OWASP XSS Prevention Cheat Sheet: reglas de encoding por contexto de salida.
- OWASP SQL Injection Prevention: queries parametrizadas, stored procedures y trampas de ORMs.
- Validación de input: patrones de validación basados en schema que complementan la sanitización.
Preguntas frecuentes
¿Debo sanitizar input del lado del cliente o del servidor?
Siempre sanitiza del lado del servidor. La sanitización del cliente mejora UX y reduce carga del servidor, pero los atacantes pueden bypassarla completamente enviando requests HTTP crudos. Los checks del cliente son una capa de conveniencia; los checks del servidor son el límite de seguridad.
¿Cuál es la diferencia entre validación y sanitización?
La validación verifica que el input cumpla con reglas esperadas (ej: "¿es este un email válido?"). La sanitización transforma input para remover constructos peligrosos (ej: "quita tags <script>"). Valida para rechazar datos malos; sanitiza para hacer seguros los datos aceptables.
¿Sanitizar input es lo mismo que escapar output?
No. La sanitización ocurre en el límite de entrada — limpia datos que llegan. El escaping (encoding) ocurre en el límite de salida — hace que los datos sean seguros para un contexto específico como HTML, JavaScript o una URL. Necesitas ambos: sanitiza lo que entra, codifica lo que sale.
¿Cómo manejo uploads de archivos de forma segura?
Valida el tipo de archivo inspeccionando magic bytes, no la extensión. Almacena uploads fuera del web root. Renombra archivos a IDs aleatorios. Sírvelos con Content-Disposition: attachment y X-Content-Type-Options: nosniff. Escanea con antivirus si es necesario.
¿Qué librería de sanitización debería usar?
Ajusta a tu stack y amenaza: bleach (Python), DOMPurify (JS) y JSoup (Java) cubren campos HTML. Para SQL, usa las queries parametrizadas de tu driver — no necesitas librería. Para comandos del OS, prefiere listas de argumentos por encima de cualquier sanitización.
¿Sanitizar input reemplaza la parametrización en SQL?
No — son defensas diferentes para problemas diferentes. La parametrización separa código de datos a nivel de query; la sanitización transforma los datos en sí. Un string sanitizado igual puede romper una query si lo concatenas. Parametriza siempre, y trata la sanitización como una capa extra, nunca un sustituto.
Recursos Relacionados
Escapar Entidades HTML
Cómo escapar entidades HTML para prevenir ataques XSS en Python, Java y JavaScript.
RecipePrevenir Cross-Site Scripting (XSS)
Cómo sanitizar input de usuario, escapar output y usar Content Security Policy para prevenir ataques XSS en aplicaciones web.
RecipePrevenir Ataques de Inyección SQL
Cómo escribir queries parametrizadas y usar ORMs para eliminar vulnerabilidades de inyección SQL en Python, JavaScript y Java.
RecipeValidación de Subida de Archivos
Cómo manejar subidas de archivos de forma segura con validación de tamaño, tipo y contenido.
RecipeValidación de Input: Schemas en 3 Lenguajes
Valida el input de usuarios en el límite de la API con librerías de schemas. Ejemplos con Pydantic, Zod y Jakarta Validation, sanitización y errores estructurados.
RecipeLimitación de Tasa (Rate Limiting)
Cómo implementar rate limiting en APIs usando token bucket, sliding window y fixed window en Python, JavaScript y Java.