Resumen teórico
Imagina tu casa. Tiene puerta de entrada, ventanas, una cerradura, una llave debajo del felpudo y un vecino que a veces pide azúcar prestada. La seguridad es el trabajo de pensar en todo eso: quién puede entrar, qué puede llevarse y qué hacemos para que la persona equivocada no pueda. El software también tiene puertas y llaves. Un sitio web tiene un inicio de sesión (la puerta de entrada), cookies (notitas que dicen "esta persona ya entró") y una base de datos (el armario donde se guarda todo lo valioso).
La mayoría de las intrusiones en internet no son cosa de película, con un hacker tecleando rápido. Ocurren porque un programador confió en algo que debió verificar: un nombre escrito en una casilla, un número en la dirección, un archivo que alguien subió. Esta materia, llamada seguridad de aplicaciones, enseña cómo fallan los programas cuando alguien los usa de una forma que nadie esperaba, para que los construyamos de modo que no fallen.
Todo aquí es defensivo. Para cada falla explicamos por qué ocurre y, siempre, cómo prevenirla. El código que ves es el ejemplo más pequeño necesario para reconocer el problema, y siempre va junto a su corrección. Las ideas siguen las guías de OWASPOpen Worldwide Application Security Project, un grupo sin fines de lucro que escribe guías gratuitas de seguridad, la referencia estándar del área.
Al final conocerás las principales familias de fallas web (inyección, scripts plantados en páginas, peticiones falsificadas, contraseñas débiles, sesiones robadas, tokens rotos, falta de comprobación de permisos, servidores engañados, subidas inseguras), los hábitos que frenan cada una y lo básico de criptografía y secretos que sostiene todo lo demás.
Cómo funciona la web: las piezas con las que juega un atacante
Antes de estudiar cómo se rompen las cosas, necesitamos saber cómo funciona normalmente un sitio web. Piensa en un restaurante. Tú (el navegador) te sientas a la mesa y escribes un pedido en un papel. El mesero lo lleva a la cocina (el servidor). La cocina cocina y el mesero trae el plato. El papel del pedido se llama petición HTTPun mensaje pequeño que el navegador envía al servidor para pedir algo, como una página o una acción, y el plato es la respuesta.
Navegador Servidor
| |
| GET /invoices/1001 + cookie |
| -------------------------------> | busca los datos,
| | arma una página
| 200 OK + página (HTML) |
| <------------------------------- |
| |Algunas palabras volverán todo el tiempo. Una cookie es una notita que el servidor le pide al navegador que guarde y que muestre de nuevo en cada visita posterior, como la pulsera de un parque acuático: el personal no recuerda tu cara, solo revisa la pulsera. Un origen es la dirección exacta de un sitio: el protocolo (https), el nombre (shop.test) y el puerto. Dos páginas con orígenes distintos son extrañas entre sí, y el navegador lo hace cumplir con la política del mismo origenuna regla del navegador: un script de un origen no puede leer datos que pertenecen a otro origen.
| Palabra | Imagen cotidiana | Qué es |
|---|---|---|
| Cliente | El comensal en la mesa | El navegador o la app del usuario. El usuario lo controla por completo |
| Servidor | La cocina | El programa que escribimos y protegemos |
| Cookie | Una pulsera | Una nota que el navegador devuelve automáticamente en cada petición |
| Origen | Una dirección de calle completa | Protocolo + nombre + puerto, por ejemplo https://shop.test:443 |
| Entrada no confiable | La nota de un extraño | Todo lo que viene de fuera: formularios, URLs, cabeceras, archivos e incluso datos leídos de vuelta de la base de datos |
OWASP Top 10, amenazas y buenos hábitos
¿Por dónde empezar? Los médicos tienen una lista de las enfermedades más comunes. Quienes trabajan en seguridad también. El OWASP Top 10 es un documento de concienciación, no es una ley ni un certificado. Agrupa las fallas en diez categorías de riesgo, ordenadas por la frecuencia con que aparecen en datos reales y por una encuesta a profesionales. Es un buen punto de partida para estudiar y revisar, pero no es una lista completa de todas las fallas, y pasar una revisión basada en él no prueba que una aplicación sea segura.
En este resumen usamos la edición de 2021. Su categoría número uno es Broken Access Control (control de acceso roto): una persona con sesión iniciada alcanza cosas que no son suyas. La inyección, que lideraba la edición de 2017, pasó al tercer lugar.
| Posición | Categoría | En una frase |
|---|---|---|
| A01 | Broken Access Control | Un usuario lee o cambia lo que no debería |
| A02 | Cryptographic Failures | Los datos sensibles no se protegen, o se protegen mal |
| A03 | Injection | Los datos se mezclan con código (SQL, shell) y se ejecutan como código |
| A04 | Insecure Design | El propio plan carece de un control necesario |
| A05 | Security Misconfiguration | Valores por defecto, funciones abiertas y errores ruidosos olvidados en producción |
| A06 | Vulnerable and Outdated Components | Bibliotecas viejas con fallas conocidas |
| A07 | Identification and Authentication Failures | Contraseñas débiles, sesiones débiles, inicio de sesión débil |
| A08 | Software and Data Integrity Failures | Actualizaciones y datos aceptados sin comprobar de dónde vienen |
| A09 | Security Logging and Monitoring Failures | Nadie nota la intrusión |
| A10 | Server-Side Request Forgery (SSRF) | Se engaña al servidor para que llame a lugares a los que no debería |
Pensar como defensor: el modelado de amenazas
El modelado de amenazasdibujar el sistema y preguntar qué puede salir mal, antes de construirlo consiste en responder cuatro preguntas temprano: ¿Qué estamos construyendo? ¿Qué puede salir mal? ¿Qué vamos a hacer al respecto? ¿Hicimos un buen trabajo? Rinde más en la fase de diseño, antes de que exista código, porque cambiar un dibujo es barato y cambiar un sistema terminado es caro. Cuando la arquitectura cambia, lo haces otra vez.
Una lista práctica para "qué puede salir mal" es STRIDE. Cada letra es un tipo de amenaza, y cada una rompe una propiedad que queremos mantener. Saber qué propiedad se rompió indica el tipo de control que se debe usar.
| Letra | Amenaza | Propiedad rota | Control típico |
|---|---|---|---|
| S | Spoofing: hacerse pasar por otra persona | Autenticidad | Autenticación fuerte |
| T | Tampering: alterar datos | Integridad | Firmas, comprobaciones en el servidor |
| R | Repudiation: negar que se hizo algo | No repudio | Registros y rastros de auditoría |
| I | Information disclosure: filtrar secretos | Confidencialidad | Cifrado, control de acceso |
| D | Denial of service: hacer que deje de funcionar | Disponibilidad | Límites, capacidad, filtrado |
| E | Elevation of privilege: obtener más derechos | Autorización | Comprobación de permisos, mínimo privilegio |
Principios que aparecen en todas partes
- Mínimo privilegio: cada persona, programa o cuenta de base de datos recibe solo los permisos que su trabajo necesita. Quien cuida la mascota del curso necesita la llave del salón, no la de toda la escuela. Si la app solo lee productos, su cuenta en la base de datos no debe poder borrar tablas.
- Defensa en profundidad: apila varias protecciones independientes, porque cualquiera puede fallar. Un castillo tiene foso, muralla y guardias.
- Seguro por defecto y denegar por defecto: la opción segura es la que obtienes cuando no haces nada.
- Diseño inseguro frente a implementación insegura: un bug es código que no hace lo que decía el plan. El diseño inseguro es código que hace exactamente lo que decía el plan, cuando el plan olvidó un control. Ejemplo: una tienda deja que cualquiera reserve stock ilimitado durante 24 horas sin pagar. Los bots reservan todo. El código no tiene bug; a la regla le falta un límite. La corrección es rediseñar la regla, y la prevención es escribir casos de abuso junto a los casos de uso.
- Mala configuración de seguridad: el código está bien pero el despliegue no. Cuentas por defecto
admin/admin, listado de directorios activado y páginas de error que muestran el stack trace completo son ejemplos clásicos. La cura es un proceso de endurecimiento (hardening) repetible, igual en todos los entornos.
Criptografía y TLS: códigos secretos que protegen el camino
La criptografíala ciencia de convertir información en una forma que solo las personas correctas pueden usar o verificar es la caja de herramientas detrás de las contraseñas, los tokens y el candado del navegador. No necesitas las matemáticas. Necesitas saber qué herramienta responde a qué pregunta.
Dos formas de cerrar un mensaje
La criptografía simétrica usa la misma clave para cerrar y abrir, como un diario con una llave que tienen los dos amigos. Es muy rápida, pero hay un problema: ¿cómo acuerdan los dos la clave sin que nadie más escuche? La criptografía asimétrica usa un par de claves. La clave pública puede mostrarse a todo el mundo, como la ranura de un buzón donde cualquiera deja cartas. La clave privada se queda en secreto, como la llave que abre el buzón. Resuelve el problema de compartir, pero es mucho más lenta.
| Aspecto | Simétrica | Asimétrica |
|---|---|---|
| Claves | Una clave secreta compartida | Una clave pública y una clave privada |
| Velocidad | Muy rápida | Mucho más lenta |
| Problema principal | Cómo compartir la clave con seguridad | El costo, por eso se usa solo un instante |
| En TLS sirve para | Cifrar todos los datos de la conexión | El handshake: probar quién es el servidor y acordar las claves |
Los sistemas reales usan ambas, lo que se llama criptografía híbrida. TLS usa la herramienta asimétrica, lenta, una vez al principio, para acordar un secreto sin ningún secreto previo. Después de eso, todo se protege con la herramienta simétrica, rápida.
Hash no es cifrado
Una función hash es como una máquina de moler carne: entra cualquier cosa, sale una "huella digital" de tamaño fijo, y no se puede volver a convertir la carne. Es de una sola vía. Un cifrado es una cerradura reversible: con la clave recuperas el original. Responden a necesidades distintas, y usar uno en lugar del otro es un error clásico.
| Herramienta | ¿Reversible? | Tamaño de la salida | Úsala para |
|---|---|---|---|
| Hash (por ejemplo SHA-256) | No | Siempre igual | Comprobar que un archivo no cambió |
| Cifrado (por ejemplo AES) | Sí, con la clave | Cerca del tamaño de la entrada | Mantener datos en secreto |
| MAC / HMAC | No | Fijo | Comprobar que un mensaje no fue alterado por quien no tiene la clave |
| Firma digital | No | Fijo | Probar quién escribió un mensaje cuando nadie comparte clave |
¿Por qué un hash simple no basta para proteger un mensaje en el camino? Porque si alguien en medio cambia el mensaje, puede simplemente calcular un hash nuevo para el mensaje nuevo. El hash no lleva ningún secreto. Un MACcódigo de autenticación de mensajes: una huella digital mezclada con una clave secreta, de modo que solo quien conoce la clave puede producir una válida como HMAC mezcla una clave secreta en el hash. Sin la clave, nadie puede producir un valor válido para un mensaje alterado. Cuando las dos partes no comparten clave, una firma digital cumple el mismo papel.
TLS: qué significa realmente el candado
TLS es el protocolo detrás de https. Cerrar la conversación no basta, porque podrías estar cerrándola para la persona equivocada. Por eso el servidor muestra un certificado, un documento firmado por una autoridad de confianza que dice "esta clave pública pertenece a este dominio". El navegador lo valida. ¿Qué prueba eso? Que la clave pública realmente pertenece al dominio que visitas, y que el servidor tiene la clave privada correspondiente. No prueba que el sitio sea honesto. Un sitio estafador puede tener un candado perfectamente válido.
1. Navegador: "hola, quiero shop.test"
2. Servidor : certificado (clave pública + firma)
el navegador revisa: ¿es de shop.test?
3. Ambos : acuerdan claves de sesión nuevas
(asimétrica, Diffie-Hellman efímero)
4. Desde aquí: cifrado simétrico rápido
+ comprobación de integridad en cada mensajeHay una propiedad más, muy buena. Supón que alguien graba hoy todo tu tráfico cifrado y, dentro de cinco años, roba la clave privada del servidor. ¿Puede leer las grabaciones antiguas? Con TLS 1.3, no. Esto se llama confidencialidad directa (forward secrecy). Cada conexión crea sus claves con un intercambio Diffie-Hellman efímerode vida corta: se usa una vez y luego se tira, con números aleatorios que se descartan al final. La clave del certificado se usa solo para probar identidad, no para proteger el tráfico, así que robarla después no abre nada del pasado.
↑ Volver al índiceInyección: cuando los datos se vuelven comandos
Imagina que le dices a un mesero robot: "Tráeme la sopa". Ahora un niño travieso escribe en la comanda: "sopa, y también abre la caja registradora". Si el robot lee la comanda como una lista larga de instrucciones, obedece las dos. Eso es inyección: el programa debería tratar lo que escribió el usuario como simples datos, pero deja que esos datos se lean como código. Los dos se mezclaron en un mismo trozo de texto.
En la inyección SQL el "robot" es la base de datos. El programa arma la consulta pegando textos, así que un valor con una comilla puede cambiar la forma del comando. El problema es el pegamento, no el usuario.
const email = body.email; // viene del usuario
const sql = "SELECT id, name FROM users WHERE email = '" + email + "'";
const result = await db.query(sql);
// un email como x' OR '1'='1 cambia el significado de la consultaconst sql = "SELECT id, name FROM users WHERE email = $1";
const result = await db.query(sql, [email]);
// la base de datos fija primero la forma del comando,
// y solo después recibe email como valor¿Por qué funciona la segunda versión? La base de datos primero lee el texto con sus marcadores ($1, o ? en otras bases) y decide cuál es el comando. Solo después recibe los valores, y un valor solo puede ocupar una posición de dato. Así, x' OR '1'='1 es apenas un email raro con el que comparar, no una condición. Esto se llama consulta parametrizadatambién llamada prepared statement: el texto SQL y los valores viajan por separado, y es la defensa principal porque elimina la causa.
Otras defensas y cosas que no funcionan
- La validación (¿el valor tiene la longitud, el tipo y el formato correctos?) es una buena capa extra. Decide si un valor tiene sentido. No reemplaza a los parámetros. Un campo de apellido debe seguir aceptando
O'Brien, así que aceptas el apóstrofo y envías el valor como parámetro. - Mínimo privilegio: una cuenta de base de datos que solo lee lo que necesita limita el daño si algo se escapa.
- Las listas de bloqueo y el escape manual (quitar comillas, prohibir palabras) tratan síntomas y son fáciles de esquivar. No cuentes con ellas.
- Los ORM (bibliotecas que escriben el SQL por ti) parametrizan sus métodos normales, así que reducen mucho el riesgo. Pero el SQL crudo armado con interpolación de cadenas dentro de un ORM es tan inyectable como cualquier otro.
- Los datos de tu propia base de datos tampoco son confiables. Si un nombre se guardó con seguridad y luego se pegó en otra consulta, esa segunda consulta es vulnerable. Parametrizar es una regla para toda consulta, no solo para lo que acaba de llegar.
- Los procedimientos almacenados no son magia. Si un procedimiento arma SQL dinámico pegando su argumento en una cadena, es inyectable. Dentro de la base de datos, el SQL dinámico necesita sus propios parámetros (en PostgreSQL,
EXECUTE ... USING).
Cuando la estructura debe cambiar
Los marcadores representan valores, no nombres de columnas, nombres de tablas ni ASC/DESC. Supón que una lista puede ordenarse por price o name. No pegues la palabra recibida en la consulta. Usa la palabra solo como clave de una lista fija y toma el nombre real de la columna de esa lista.
const columns: Record<string, string> = { price: "price", name: "name" };
const column = columns[query.sort];
if (!column) return status(400);
const sql = `SELECT id, name FROM products ORDER BY ${column}`;
// el texto sale de nuestra lista, nunca del usuarioLos parámetros tampoco cambian lo que un valor significa dentro de un mini-lenguaje propio. En LIKE '%' || $1 || '%', si el usuario envía %, no hay inyección, pero % sigue funcionando como el comodín "cualquier cosa" y coincide con todas las filas. Si la búsqueda debe ser literal, escapa % y _ en el valor.
La misma idea con el sistema operativo
La inyección de comandos es el mismo error con otro intérprete: el shell. Un servidor que arma el texto ping -c 1 más un host escrito por el usuario y se lo entrega a un shell está en problemas, porque caracteres como ;, | y && significan "ejecuta otro comando" para un shell. La cura: no uses el shell. Inicia el programa directamente, indicando el ejecutable y una lista de argumentos, para que cada argumento llegue como dato, y valida el host contra un formato estricto.
// Vulnerable: un shell lee la cadena completa
exec("ping -c 1 " + host);
// Corregido: sin shell, argumentos en lista, host validado antes
if (!/^[a-zA-Z0-9.-]{1,253}$/.test(host)) return status(400);
spawn("ping", ["-c", "1", host]);Cross-site scripting (XSS): el script de un extraño en tu página
La inyección tiene una prima en la que el "robot" es el navegador. Un navegador lee texto HTML y construye la página a partir de él. Algunas partes del HTML son código, como un bloque <script> o un atributo como onerror="...". En el cross-site scripting (XSS), un script escrito por otra persona termina dentro de tu página, y el navegador no puede saber que no lo escribiste tú. Se ejecuta con el origen del sitio vulnerable, así que ve la página, lee lo que la página puede leer y actúa en nombre de quien la tiene abierta.
La causa raíz es siempre la misma: se escribieron datos en un lugar que se interpreta como código sin codificarlos para ese lugar. Hay tres tipos clásicos, y la diferencia es solo cómo llegan los datos a la página.
| Tipo | Cómo llegan los datos | Ejemplo |
|---|---|---|
| Almacenado (stored) | El servidor lo guarda y lo sirve a todos los que abren la página | Un comentario con una etiqueta de script, mostrado a cada lector del artículo |
| Reflejado (reflected) | El servidor devuelve de inmediato algo que vino en la petición | Una página de búsqueda que imprime tu texto buscado sin cambios |
| Basado en DOM | El JavaScript de la propia página lee los datos y los inserta en la página. El servidor puede no verlos nunca | Un script que pone en la página lo que va después del # en la dirección |
Defensa 1: codificar en la salida
Cuando escribas datos del usuario en HTML, convierte los caracteres de significado especial en equivalentes inofensivos: < pasa a <, > pasa a >, & pasa a &, y las comillas también se reemplazan. El navegador entonces muestra los caracteres como texto en vez de leerlos como etiquetas. No se borra nada; el visitante ve exactamente lo que escribió.
Sin codificación:
el servidor escribe : <span> <script>bad()</script> </span>
el navegador ve : una etiqueta script -> SE EJECUTA
Con codificación:
el servidor escribe : <span> <script>bad()</script> </span>
el navegador ve : texto plano -> se muestra, nunca se ejecutaCodifica en el momento de la salida, no al guardar en la base de datos. La codificación correcta depende de dónde cae el dato (texto HTML, atributo, URL, JavaScript, CSS), y eso solo lo sabes al escribir la página. Un dato guardado ya codificado para HTML se vería mal en una API JSON y seguiría siendo inseguro dentro de JavaScript. El contexto importa: algo inofensivo en un lugar puede ser código en otro. En un href o src, por ejemplo, también hay que comprobar el esquema de la URL, porque un enlace javascript: ejecuta código al hacer clic. Acepta solo http: y https:, de una lista de permitidos.
Defensa 2: APIs seguras del navegador
En el XSS basado en DOM el servidor no tiene nada que codificar, porque la parte peligrosa nunca viaja hasta él. La corrección está dentro del script: usa APIs que traten el valor como texto.
const name = decodeURIComponent(location.hash.slice(1));
// Vulnerable: innerHTML interpreta el texto como HTML
// document.querySelector('#greeting').innerHTML = 'Hola, ' + name;
// Corregido: textContent siempre inserta texto plano
document.querySelector('#greeting').textContent = 'Hola, ' + name;| Seguro: trata el valor como texto | Peligroso: interpreta el valor como marcado |
|---|---|
textContent, createTextNode | innerHTML, outerHTML |
setAttribute para atributos que no ejecutan código | document.write, insertAdjacentHTML |
Frameworks, sanitizadores y lo que queda
- Los frameworks modernos de interfaz codifican automáticamente los valores que interpolas en las plantillas. El XSS vuelve cuando usas su salida de "HTML crudo" con datos del usuario, pones una URL del usuario en un
hrefo tocas el DOM directamente. - Si los usuarios deben poder enviar formato (negrita, listas, enlaces), la codificación lo arruinaría. Pasa el HTML por un sanitizador mantenido que conserve solo una lista de permitidos de etiquetas, atributos y esquemas de URL. Las listas de permitidos fallan de forma segura: todo lo desconocido se elimina. No escribas tú un sanitizador; son muy difíciles de hacer bien.
- Las cookies
HttpOnlyson una mitigación, no una corrección. El script no puede leer esa cookie, pero aún puede enviar peticiones como la víctima, porque el navegador adjunta la cookie por sí solo. La corrección sigue siendo la codificación y los destinos seguros, con la Content Security Policy como capa extra (siguiente sección).
Content Security Policy y cabeceras de seguridad
La codificación es la defensa principal contra el XSS, pero los programadores olvidan cosas. Por eso añadimos una red de seguridad: le decimos al navegador, de antemano, qué puede hacer la página. Piensa en una excursión escolar: la profesora le entrega al conductor del autobús una lista escrita de los lugares que el grupo puede visitar. Si un extraño intenta añadir una parada, el conductor se niega. Esa lista es una Content Security Policy (CSP).
La CSP es una cabecera HTTP del servidor. Es una lista de permitidos aplicada por el navegador: dice de qué orígenes puede la página cargar y ejecutar contenido. Reduce el daño cuando la codificación de salida falla en algún punto, pero no la reemplaza.
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'| Directiva | Significado |
|---|---|
script-src 'self' | Solo se ejecutan archivos de script del mismo origen. Los bloques <script> en línea y los manejadores en línea como onclick se rechazan |
default-src 'self' | Todo lo que no se listó (imágenes, estilos, conexiones) solo del mismo origen |
object-src 'none' | Sin plugins como <object> o <embed> |
base-uri 'none' | Sin elemento <base> que podría cambiar a dónde apuntan las direcciones relativas |
form-action 'self' | Los formularios solo pueden enviarse al mismo origen |
frame-ancestors 'none' | Nadie puede poner esta página dentro de un <iframe> |
Con script-src 'self', si un comentario mal codificado cuela un script en línea en la página, el navegador ejecuta /app.js (un archivo del mismo origen) y bloquea el script en línea. Esa es la ganancia principal: la mayoría de las inyecciones parecen marcado dentro de la página. Para aprovecharla, tu JavaScript debe estar en archivos, no en atributos de evento.
No rompas la red
Cuando el código viejo necesita scripts en línea, alguien puede tentarse a escribir script-src 'self' 'unsafe-inline'. La palabra clave se llama así por algo: le devuelve al atacante el camino más fácil, y la política pasa a valer poco contra XSS. Las formas correctas de mantener un script en línea necesario son moverlo a un archivo o autorizarlo individualmente con un nonce o un hash.
Un nonce"number used once", número usado una vez: un valor impredecible creado nuevo para cada respuesta se pone tanto en la cabecera (script-src 'nonce-R4nd0m') como en el atributo nonce de tus propias etiquetas de script. Un script inyectado no conoce el valor de esa respuesta y se bloquea. Si el valor es fijo en un archivo de configuración, es igual en todas las páginas, cualquiera puede leerlo una vez y ponerlo en su propia etiqueta, y la protección desaparece.
Desplegarla con seguridad
En un sitio grande y viejo, activar una política estricta puede romper páginas que usan algo que olvidaste. El camino seguro es Content-Security-Policy-Report-Only. El navegador evalúa la política sin aplicarla y envía informes de violaciones. Corrige lo que muestren los informes y, cuando estén limpios, mueve la misma política a la cabecera Content-Security-Policy real.
Otras cabeceras de seguridad
| Cabecera | Qué hace | Por qué importa |
|---|---|---|
Strict-Transport-Security (HSTS) | Le dice al navegador que use solo HTTPS en este dominio durante max-age | Sin ella, la primera petición puede ir por HTTP y alguien en el camino puede impedir el salto a HTTPS. Solo cuenta cuando se recibe por HTTPS |
X-Content-Type-Options: nosniff | El navegador debe respetar el Content-Type declarado y no adivinar | Evita que un archivo subido lleno de HTML se ejecute porque el navegador creyó que era una página |
frame-ancestors (en CSP) | Declara quién puede enmarcar la página | Defiende del clickjacking: tu página cargada invisible en el marco de otro sitio para robar clics. 'none' no permite a nadie, 'self' solo a tu propio origen |
CSRF y cookies SameSite: peticiones falsificadas
Imagina una pizzería que entrega a quien tenga tu tarjeta de cliente frecuente. Un bromista no necesita robar tu tarjeta. Solo grita "¡Oye, pizzería, entrega 50 pizzas en esta dirección!" de una forma que hace que tu propio teléfono haga el pedido, y la pizzería ve el número de tu tarjeta y entrega. Eso es cross-site request forgery (CSRF): otro sitio web hace que el navegador de la víctima envíe una petición que el sitio objetivo acepta como propia de la víctima.
Fíjate en lo que el atacante no hace. No lee la respuesta ni roba la contraseña. Solo necesita que se ejecute una acción con los derechos de la víctima, como cambiar un correo o transferir dinero. Por eso el CSRF apunta a peticiones que cambian estado.
Funciona por cómo se comportan las cookies. Una cookie de sesión es una credencial ambiental: el navegador la adjunta por sí solo a toda petición a ese sitio, incluso cuando la petición la inició una página de otro sitio. El servidor recibe una petición perfectamente válida y no puede saber que el usuario no la quiso.
página evil.test Navegador bank.test
| form oculto a bank.test | |
| -------------------------> | |
| | POST /transfer |
| | + cookie bank.test |
| | -----------------> |
| | servidor: sesión |
| | válida, ¡hazlo! |Defensa 1: cookies SameSite
El atributo SameSite le dice al navegador cuándo puede enviarse la cookie en peticiones entre sitios. Un sitio es el dominio registrable (por ejemplo example.com), una idea más grande que la de origen.
| Valor | La cookie se envía | Contrapartida |
|---|---|---|
Strict | Solo en peticiones hechas desde el mismo sitio | El más seguro. Quien llega desde un enlace externo parece "sin sesión" en la primera página |
Lax | También en navegaciones de nivel superior con método seguro, como hacer clic en un enlace (GET) | Un buen valor por defecto, pero supone que GET no tiene efectos secundarios |
None | Siempre, incluso entre sitios; exige Secure | Ninguna protección contra CSRF |
Lax no es magia. Si tu sitio tiene GET /account/delete, un enlace en otro sitio que apunte allí será pulsado por la víctima y el navegador envía la cookie, así que la cuenta se borra. Nunca cambies datos con GET. Usa POST, PUT, PATCH o DELETE, protegidos por un token.
Recuerda también que SameSite protege contra otros sitios, no contra otros orígenes del mismo sitio. Si app.example.com y pages.example.com comparten el dominio registrable example.com, y los clientes publican su propio HTML en pages, una página publicada allí es del mismo sitio que tu aplicación. SameSite=Strict no retiene la cookie. Pon el contenido no confiable en un dominio registrable aparte y mantén el token como defensa principal.
Defensa 2: el token anti-CSRF
En el patrón del token sincronizador, el servidor genera un valor aleatorio ligado a la sesión, lo pone en un campo oculto de sus formularios y lo comprueba en cada POST. La cookie va sola, pero el token no se envía automáticamente. La página falsificada no puede leer tu página real para copiar el token, porque la política del mismo origenla regla del navegador que impide que una página de un origen lea páginas de otro lo prohíbe.
<form method="post" action="/transfer">
<input type="hidden" name="csrf" value="9f3a...aleatorio-por-sesion">
<input name="amount">
</form>
<!-- servidor: rechaza el POST si csrf falta o no coincide -->Esto también explica por qué el XSS derrota al token: un script inyectado está en el mismo origen, así que tiene permiso para leer el token.
Dos malentendidos comunes
- "No configuramos CORS, así que los otros sitios están bloqueados." Falso. La política del mismo origen y CORS protegen la lectura de respuestas entre orígenes. No impiden que un POST simple de formulario se envíe y se procese. El CSRF no necesita leer nada. La defensa debe vivir en el servidor, con token y
SameSite. - "Nuestra API usa un token Bearer, ¿estamos expuestos?" Poco. Si el token se guarda en memoria y se envía explícitamente en la cabecera
Authorization, el navegador no lo adjunta a peticiones falsificadas desde otro sitio. El CSRF es un problema de credenciales ambientales: cookies, autenticación Basic, certificados de cliente. El precio es que los scripts de la página alcanzan el token, así que la defensa contra XSS pesa aún más.
Autenticación: contraseñas, hashing y más de un factor
Dos palabras se confunden fácil. Autenticación responde "¿quién eres?". Autorización responde "¿puedes hacer esto?". En un cine, mostrar la entrada en la puerta es autenticación; poder entrar solo a la sala 3 y no a la sala 5 es autorización. Un sistema puede hacer la primera perfectamente y la segunda mal, y al revés. Son controles distintos, con fallas distintas.
Cómo guardar una contraseña
El servidor nunca necesita saber tu contraseña, solo reconocerla. Así que guarda la salida de una función de una sola vía y, al iniciar sesión, repite el cálculo con lo que escribiste y compara. Las contraseñas no se cifran (quien obtuviera la clave se llevaría todas las contraseñas); se les aplica hash.
Dos ingredientes hacen que un hash sea apto para contraseñas. Primero, una sal (salt): un valor aleatorio, distinto para cada contraseña y guardado junto al hash. Sin ella, dos usuarios con la misma contraseña tienen el mismo hash, y un atacante puede usar una tabla precalculada de contraseñas comunes para romper muchas cuentas a la vez. Con sal, cada fila es un problema aparte. La sal no es secreta. Segundo, el hash debe ser lento a propósito.
¿Por qué lento? Los hashes rápidos como MD5 o SHA-256 se hicieron para comprobar archivos deprisa, lo cual es estupendo para archivos y terrible para contraseñas, porque quien tiene una tabla robada puede probar millones de intentos por segundo. Argon2id, scrypt y bcrypt añaden un costo que puedes subir con el tiempo; Argon2id y scrypt además exigen mucha memoria, lo que hace caras las tarjetas gráficas (GPU). Un SHA-256 con sal sigue siendo inadecuado: la sal frena el ataque masivo, pero solo el costo frena cada cuenta individual.
- MD5, sin sal
- 843,636 hashes por segundo
- SHA-256 + sal de 16 bytes
- 548,724 hashes por segundo
- Argon2id 19 MiB, t=2
- 22 hashes por segundo
- Argon2id 64 MiB, t=3
- 3.5 hashes por segundo
Lee el gráfico como el número de intentos por segundo que un atacante puede probar en una tabla robada, por núcleo. Argon2id hace que cada intento cueste unos 45 milisegundos y decenas de megabytes de memoria, aproximadamente 38.000 veces más que MD5 en esa ejecución. Un usuario paga ese costo una vez por inicio de sesión y no lo nota. La máquina, las versiones y el comando están registrados en los resultados del laboratorio.
| Esquema | Una vía | Sal | Lento y exigente en memoria |
|---|---|---|---|
| Texto plano | no | no | no |
| MD5 | sí | no | no |
| SHA-256 con sal | sí | sí | no |
| Argon2id | sí | sí | sí |
Una pimienta (pepper) es distinta de una sal. La pimienta es un secreto compartido por toda la aplicación y guardado fuera de la base de datos (en una bóveda o en el entorno). Ayuda cuando solo se filtra la base de datos, por ejemplo por una inyección SQL o una copia de seguridad robada. Si se toma el servidor entero, la pimienta se filtra también, así que es una capa extra, no un reemplazo.
¿Y si un sistema viejo guarda solo MD5(contraseña) y queremos a todos protegidos con Argon2id ya, sin conocer las contraseñas? Guarda Argon2id(MD5 viejo) para todas las cuentas a la vez y registra qué esquema usa cada fila. En el siguiente inicio de sesión exitoso de cada usuario tienes la contraseña real en la mano, así que guarda Argon2id simple y descarta la envoltura.
Pantallas de inicio de sesión y políticas
- Di lo mismo en cada fallo. Si la pantalla responde "usuario no encontrado" para correos desconocidos y "contraseña incorrecta" para los conocidos, regalas una lista de quién tiene cuenta. Responde "credenciales inválidas" siempre, con el mismo código de estado y un tiempo parecido. Lo mismo aplica a las pantallas de registro y de recuperación de contraseña.
- Limita los intentos, pero piensa en quién controla el contador. Bloquear una cuenta para siempre tras 3 contraseñas erróneas permite que cualquiera bloquee a cualquiera. Usa bloqueos cortos que crecen, límites por cuenta y por origen, y un segundo factor.
- Sigue la guía actual (NIST SP 800-63B). Exige una longitud mínima, acepta frases largas y cualquier carácter, permite pegar, rechaza contraseñas ya encontradas en filtraciones y no obligues a cambios periódicos sin motivo. La longitud y la unicidad valen más que reglas raras de complejidad.
Factores
Un factor es una categoría de prueba. Hay tres: algo que sabes (contraseña, PIN), algo que tienes (un teléfono, una llave de seguridad) y algo que eres (huella digital, rostro). La autenticación multifactor de verdad combina categorías distintas, para que robar una contraseña no baste. Una contraseña más un código temporal de una app autenticadora son dos factores. Una contraseña más una pregunta secreta no lo son: ambas son "algo que sabes".
↑ Volver al índiceJWT, OAuth 2.0 y OpenID Connect: tokens e inicio de sesión delegado
Algunos sistemas no guardan una sesión en el servidor. En cambio te dan una nota firmada, un JSON Web Token (JWT), y confían en lo que está escrito en ella, mientras la firma compruebe. Es como una pulsera de concierto con holograma: el guardia no llama a la taquilla, solo revisa el holograma. Un JWT son tres textos unidos por puntos:
base64url(header) . base64url(payload) . firma
| | |
cómo se firmó las claims prueba de que el emisor
{"alg":"HS256"} {"sub":"bob", escribió las dos
"exp":1800000900} primeras partesDos hechos le dan forma a todo. Primero, el payload solo está codificado en base64url, no cifrado. Cualquiera que tenga el token lo lee con cualquier herramienta, así que nunca pongas secretos ni datos personales innecesarios en él. La firma garantiza que nada cambió y quién lo emitió, no el secreto. Segundo, el header lo escribe quien envía. Todo lo que el verificador lee de él antes de comprobar la firma es una sugerencia de un extraño.
Las comprobaciones que debe hacer un verificador
| Comprobación | Por qué |
|---|---|
El alg del header coincide con el algoritmo que el servidor espera | Si no, quien envía decide si habrá alguna verificación. El algoritmo none significa "sin firma"; un verificador que obedece al token acepta una nota falsificada |
| La firma es válida | Prueba que lo emitió quien tiene la clave y que no cambió |
exp es posterior a ahora (una pequeña tolerancia está bien) | La expiración limita el daño de un token filtrado. Un verificador que nunca compara exp acepta tokens viejos para siempre |
iss es el emisor en el que confías | El token viene del lugar correcto |
aud incluye este servicio | Un token hecho para el servicio de informes no debe funcionar en el servicio de pagos, aunque la firma sea correcta |
Una firma válida por sí sola no basta: el token puede ser auténtico pero estar vencido, o ser auténtico pero estar destinado a otro servicio. Las bibliotecas maduras comprueban todo esto por defecto y les pasas una lista explícita de algoritmos permitidos (normalmente uno). Los verificadores escritos a mano suelen olvidar pasos.
Otra trampa es el secreto. Con HS256, la misma clave firma y verifica, así que toda la fuerza es la aleatoriedad de la clave. Si la clave es una palabra humana como secret, un atacante que tenga un token capturado puede probar intentos en su propio computador, sin conexión, sin tocar el servidor y sin ningún bloqueo, hasta que uno reproduzca la firma. Entonces puede firmar cualquier payload. Una clave HS256 necesita al menos 256 bits generados al azar, nunca una palabra memorable.
Además, un token sin estado no se puede retirar: tras un cierre de sesión o un robo funciona hasta exp. El diseño habitual es un token de acceso de vida corta (minutos) más un refresh token guardado en el servidor, rotado y revocable.
OAuth 2.0 y OpenID Connect
Imagina que le das al valet la llave del auto, pero una llave especial que solo abre las puertas y arranca el motor, no la guantera ni el maletero. OAuth 2.0 es esa idea: permite que una app actúe en tu nombre con derechos limitados (los scopes), sin ver nunca tu contraseña. Resuelve la autorización delegada: "¿puede esta app acceder a ese recurso en nombre del usuario?" El resultado es un access token destinado a una API.
OAuth no dice quién es el usuario. OpenID Connect (OIDC) es una capa delgada sobre OAuth que añade autenticación. Entrega un ID token, un JWT firmado con la identidad del usuario, y un endpoint de información del usuario.
| Aspecto | Access token | ID token |
|---|---|---|
| Responde | ¿Puede esta app llamar a esa API, con qué scopes? | ¿Quién inició sesión? |
| Destinado a | La API (servidor de recursos) | La propia app cliente (su aud es el id del cliente) |
| ¿Se envía a la API? | Sí, en Authorization: Bearer | No. Una API que acepta ID tokens deja de comprobar que el permiso se concedió para ella |
Las apps como las aplicaciones móviles y las single-page applications no pueden guardar un secreto de cliente. El flujo recomendado hoy para todo cliente es el flujo de código de autorización con PKCE, no el antiguo flujo implícito. Con PKCEProof Key for Code Exchange: un secreto creado nuevo en cada intento de inicio de sesión, la app crea un code_verifier aleatorio, envía su hash (code_challenge) al pedir el código y envía el valor original al cambiar el código por tokens. Quien intercepta el código de autorización pero no inició el flujo no tiene el verifier, así que el código le resulta inútil.
Control de acceso: IDOR, roles y mass assignment
En una escuela, tu carné de estudiante te deja entrar al edificio (autenticación). Pero no abre la sala de profesores ni los casilleros de otros estudiantes (autorización). El control de acceso roto ocurre cuando un programa olvida comprobar la segunda parte. Fue la categoría número uno del OWASP Top 10 (2021).
La forma más famosa es el IDOR (insecure direct object reference, referencia directa insegura a objeto). Un usuario con sesión iniciada cambia /invoices/1001 por /invoices/1002 en la barra de direcciones y ve la factura de otra persona. El número en la URL, el cuerpo o un campo oculto es solo una solicitud. El servidor debe decidir, en cada petición, si este usuario puede acceder a aquel objeto. Haber iniciado sesión no basta.
// Vulnerable: sabe quién eres, nunca pregunta si es tuyo
const invoice = await db.invoices.findById(params.id);
// Corregido: el dueño forma parte de la propia consulta
const invoice = await db.invoices.findOne({
id: params.id,
ownerId: session.user.id,
});
if (!invoice) return status(404);Poner el dueño en la consulta (WHERE id = $1 AND owner_id = $2), o pasar por una única función de política central, hace que ninguna ruta pueda olvidar la regla.
Dos direcciones de la misma falla
| Dirección | A qué llega el usuario | Ejemplo |
|---|---|---|
| Horizontal | Datos de otro usuario del mismo nivel | IDOR: leer la factura de otro cliente |
| Vertical | Una función de un rol superior | Un usuario común escribe /admin/users y la página responde |
Ocultar el enlace "Gestionar usuarios" a los usuarios comunes es solo navegación. La ruta debe comprobar el rol por sí misma. Todo lo del cliente (páginas, apps, campos ocultos) está bajo el control del usuario. La interfaz puede ocultar cosas por amabilidad, pero la decisión de seguridad ocurre en el servidor, en cada petición, preferiblemente en un único mecanismo reutilizado.
Buenas reglas
- Denegar por defecto. Rechaza todo excepto lo que una regla permite explícitamente. Con una lista de permitidos, una regla olvidada bloquea un uso legítimo, y alguien se queja pronto. Con una lista de bloqueo, una regla olvidada expone datos en silencio. Estás eligiendo la manera en que el sistema falla.
- Los ids impredecibles no son control de acceso. Cambiar números secuenciales por UUID aleatorios es un extra útil contra las adivinanzas, pero un identificador está hecho para circular. Si otra persona obtiene el id por cualquier medio, una comprobación ausente aún la deja entrar.
- Cuidado con el mass assignment. Si una ruta deja a los usuarios editar su propio perfil y hace
update(session.user.id, body), un usuario puede añadir"role": "admin"al cuerpo y ascenderse, aunque se haya comprobado la propiedad. La autorización también aplica a los campos. Valida el cuerpo con un esquema que liste los campos permitidos y rechace el resto. - Elige el código de rechazo a propósito. Un servicio de repositorios privados puede responder 404 en lugar de 403 cuando el repositorio existe pero no es tuyo, porque un 403 confirma que existe. Ambos son rechazos válidos; el 404 oculta la existencia del recurso. De cualquier modo, la decisión está en el servidor y la respuesta es consistente, tiempo incluido.
SSRF, path traversal y subida de archivos
Esta sección trata de tres formas de engañar a un servidor para que use algo que no debería: una dirección web, una ruta de archivo o un nombre de archivo que vino de fuera.
SSRF: el servidor servicial
Muchas funciones hacen que el servidor actúe como un navegador: vistas previas de enlaces, "importar desde una URL", webhooks, descargadores de imágenes. Imagina pedirle a un amable empleado de oficina: "por favor, llama a este número por mí". Si tú eliges el número, puedes pedirle que llame a la línea privada del jefe, a la que solo llegan los de dentro de la oficina. En el server-side request forgery (SSRF) el usuario aporta una URL y el servidor la descarga desde dentro de la red de la empresa, así que puede alcanzar localhost, servicios internos y la dirección de metadatos de la nube, que no tienen contraseña porque "solo las máquinas internas pueden conectarse". Tiene su propia categoría en OWASP (A10, 2021).
La corrección sigue a la causa: la aplicación debe decidir a dónde está dispuesta a conectarse, y hacer la comprobación sobre lo que la conexión realmente usa, la dirección numérica, en cada salto.
- Una lista de bloqueo de texto no funciona. Rechazar URL que contienen
localhosto127.0.0.1falla, porque la misma dirección tiene muchas grafías y cualquier nombre DNS puede apuntar hacia dentro. Analiza la URL, acepta solohttpyhttps, resuelve el nombre y rechaza direcciones de loopback, privadas y link-local. Cuando se conoce el conjunto de destinos, una lista de permitidos de hosts es más fuerte que cualquier lista de bloqueo. - Las redirecciones son una trampa. Un sitio permitido puede responder
302con unLocationque apunta a un servicio interno, y los clientes HTTP siguen las redirecciones por defecto. Desactiva las redirecciones automáticas, síguelas tú hasta un límite pequeño y vuelve a comprobar cada nuevo destino. - Comprobar y usar deben ser la misma resolución. Si resuelves el nombre, lo compruebas, y luego le das el nombre al cliente y este vuelve a resolver, un servidor DNS malicioso puede responder con una dirección pública la primera vez y una interna la segunda (DNS rebinding). Resuelve una vez, valida, conéctate a esa misma dirección y conserva el nombre solo para la cabecera
Hosty para TLS. - Añade reglas de red también. Ejecuta el componente que descarga URL de usuarios en un segmento aislado o detrás de un proxy de salida, y autentica entre servicios internos. Las comprobaciones de la aplicación son una capa.
- Ocultar la respuesta no es una corrección. La petición igual se hace (SSRF ciego).
URL del usuario
-> analizar: ¿esquema http/https? ¿host en la lista de permitidos?
-> resolver el host una vez -> ¿todas las direcciones públicas?
-> conectar a ESA dirección (cabecera Host = nombre)
-> ¿redirección? empezar de nuevo desde arriba (máx. 3 saltos)Path traversal: salir de la carpeta
Una ruta de descarga une la carpeta uploads con el nombre de archivo de la petición. Un nombre como ../../etc/something usa la secuencia .., que significa "sube una carpeta". El sistema operativo la resuelve como siempre, y el archivo leído queda fuera de la carpeta de subidas. Esto rompe la suposición de que un nombre se queda dentro de una carpeta.
// Vulnerable: join confía en el nombre
const target = path.join(UPLOADS, name);
// Corregido: valida el RESULTADO, no la entrada
const target = path.resolve(UPLOADS, name);
if (!target.startsWith(UPLOADS + path.sep)) return status(400);Valida la ruta absoluta final (resolviendo también los enlaces simbólicos) y comprueba que está dentro de la raíz permitida. Mejor aún, nunca aceptes rutas del cliente: recibe un identificador y búscalo en un índice. Ejecuta el proceso con pocos permisos, porque el alcance de la falla es todo lo que el proceso puede leer.
Subida de archivos: el nombre, el tipo y el contenido son afirmaciones
Cuando un usuario envía un archivo, el nombre, la extensión y el Content-Type declarado son solo texto que eligió el cliente. Una cabecera que dice image/png no prueba nada. Una subida segura combina varias medidas.
| Medida | Por qué |
|---|---|
| Generar un nombre aleatorio en el servidor, guardando el nombre original como metadato | El nombre del cliente nunca se usa en el sistema de archivos, así que no puede recorrer ni sobrescribir |
| Lista de permitidos de tipos, y comprobar los primeros bytes (la firma del archivo) | El tipo declarado es solo una afirmación |
| Límite de tamaño | Evita llenar el disco o la memoria |
Guardar fuera de carpetas ejecutables o servidas directamente; enviar X-Content-Type-Options: nosniff | Un archivo lleno de HTML no debe ejecutarse como página o programa |
Secretos, dependencias y la cadena de suministro
Un secreto es cualquier cosa que prueba quién eres ante otro sistema: una clave de API, una contraseña de base de datos, una clave privada. Piensa en las llaves de la escuela. No las pegas en la puerta de entrada, ni haces una copia para cada visitante. Los secretos se tratan igual.
Dónde viven los secretos
Un secreto es configuración, no código. Entra al proceso en tiempo de ejecución, desde variables de entorno o desde un gestor de secretos (una bóveda) que controla quién puede leerlo, registra cada uso y facilita la rotación. El repositorio guarda solo los nombres de las variables esperadas, nunca los valores.
¿Y si una persona desarrolladora hace commit de una clave por error y luego borra la línea en un commit nuevo? La clave sigue en el historial, y las copias del repositorio se esparcen rápido. Así que la primera acción eficaz es revocar la clave y emitir una nueva (rotación). Solo después limpia el historial, comprueba si la clave se usó y evita que vuelva a pasar con escaneo de secretos antes de los commits.
- Revoca la clave filtrada y crea una nueva.
- Revisa en los registros cualquier uso de la clave vieja.
- Limpia el historial y las copias, si es posible.
- Añade escaneo de secretos antes de los commits para que no vuelva a pasar.
Dependencias: el código de otros
La mayor parte de tu aplicación es código escrito por otras personas: bibliotecas y las bibliotecas de esas bibliotecas. Esa es la cadena de suministro. Dos ideas la protegen.
- Lockfile. Archivos como
bun.lockopackage-lock.jsonregistran la versión exacta y el hash de cada dependencia. Sin uno, un rango como^1.2.0instala lo más nuevo de ese día, y máquinas distintas pueden ejecutar código distinto. Haz commit del lockfile y úsalo en modo estricto en las compilaciones. - Sigue verificando, aunque no cambies nada. Una aplicación publicada hace un año con todas las dependencias auditadas puede ser vulnerable hoy, porque se divulgan fallas nuevas en versiones que ya instalaste. Sabe qué tienes instalado (dependencias indirectas incluidas), sigue los avisos de seguridad, actualiza a menudo y elimina lo que no uses. Esta es la categoría de OWASP Vulnerable and Outdated Components.
Juntando todo: una falla, un hábito
Todas estas fallas parecen distintas, pero la mayoría comparte una de dos raíces. O el programa mezcló datos con código (inyección, XSS), o el programa confió en una afirmación de fuera (un id, un rol, un nombre de archivo, una URL, un header de token) sin comprobarla en el servidor. La tabla de abajo conecta cada falla con el hábito que la frena.
| Falla | Causa raíz | Defensa principal | Capas extra |
|---|---|---|---|
| Inyección SQL / de comandos | Datos pegados en el texto del comando | Parámetros, listas de argumentos | Validación, mínimo privilegio, lista de permitidos para la estructura |
| XSS | Datos interpretados como marcado o script | Codificación en la salida, textContent | Sanitizador, CSP, comprobación del esquema de la URL |
| CSRF | El navegador envía cookies por sí solo | Token anti-CSRF | SameSite, sin cambiar estado con GET |
| Contraseñas débiles | Hash rápido o sin sal | Argon2id / bcrypt con sal | Pimienta, MFA, límites de intentos |
| Robo / fijación de sesión | Id predecible o reutilizado, el servidor conserva la sesión tras cerrar sesión | Id aleatorio, id nuevo al iniciar sesión, cierre de sesión en el servidor | HttpOnly, Secure, __Host-, tiempos límite |
| Errores de JWT | Confiar en el header y saltarse las claims | Algoritmo fijado, clave fuerte | Comprobaciones de exp, iss, aud |
| IDOR / mass assignment | Sin comprobación de dueño ni de campo | Autorizar en el servidor, en cada petición | Denegar por defecto, lista de campos permitidos |
| SSRF | El servidor descarga una dirección elegida por el usuario | Lista de permitidos + comprobación de la dirección resuelta | Redirecciones manuales, conexión fijada, reglas de salida de red |
| Path traversal / subida | Se acepta el nombre o tipo del cliente | Validar la ruta resuelta, nombre de archivo aleatorio | Comprobación de la firma del contenido, nosniff |
| Secreto filtrado | Secreto en el repositorio | Entorno o bóveda, rotar | Escaneo de secretos |
| Componente desactualizado | Fallas conocidas en bibliotecas viejas | Lockfile + avisos + actualizaciones | Eliminar paquetes sin usar |
Errores comunes que evitar
- Corregir el síntoma (prohibir algunos caracteres) en lugar de la causa (mantener separados código y datos).
- Ocultar cosas en la interfaz y llamarlo control de acceso.
- Usar una defensa y detenerse. Las capas fallan de una en una; apílalas.
- Tratar el OWASP Top 10 como una lista completa o un certificado.
- Creer que cifrar o aplicar hash a algo es lo mismo que autenticarlo.