Seguridad en el desarrollo de software
Por qué una app que "anda bien" puede ser insegura
Unidad 4 - Seguridad Informática - Lic. en Gestión de Tecnologías de la Información (UNPAZ)
Trabajo práctico. Demostración en vivo con una app propia: "Notas".
Seguridad en el desarrollo: de qué hablamos
La mayoría del software no es malicioso, pero puede tener vulnerabilidades que, si se explotan, permiten que un atacante tome el control o acceda a datos que no le corresponden. La raíz casi siempre es la misma: confiar en datos que vienen de afuera.
Para desarrollar seguro hay que tener en cuenta, entre otras cosas:
- Validación de la entrada: nunca confiar en lo que manda el usuario; usar listas blancas y escapar metacaracteres.
- Autenticación y gestión de sesiones: contraseñas con hash+salt, bloqueo por intentos, cookies e IDs de sesión seguros.
- Control de acceso: autenticación, autorización y auditoría; denegar por defecto.
- Criptografía: proteger datos sensibles; no inventar algoritmos ni hardcodear claves (van en variables de entorno).
- Configuración / hardening: minimizar la superficie de ataque y no filtrar información.
- Mínimos privilegios y dependencias actualizadas; registro y monitoreo.
El estándar de referencia
OWASP es una organización sin fines de lucro; su Top 10 lista los riesgos más críticos en aplicaciones web, ordenados por criticidad. Riesgo = probabilidad x impacto.
Resaltados en amarillo (A01, A02, A03, A05 y A07): los que se demuestran con código en esta presentación.
Cómo se demuestra
- Armé una app real de notas privadas: la promesa es "solo vos ves tus notas".
- Node + Express + EJS + SQLite. La sesión viaja en una cookie con un identificador (
sid). - Un interruptor global cambia toda la app entre modo VULNERABLE y modo SEGURO.
- Para el usuario la app se ve igual; lo que cambia es el código que corre por debajo.
La idea de cada lámina: el mismo ataque funciona en VULNERABLE y falla en SEGURO. Se compara el código lado a lado.
Reflejar la entrada sin escapar
Inseguro
Resultados para: <%- q %>
<%- %> imprime el texto tal cual. Si q es un <script>, se ejecuta.
Seguro
Resultados para: <%= q %>
<%= %> escapa el HTML. Además la CSP con nonce bloquea scripts inline.
- Situación: la víctima está logueada en Notas (tiene su cookie de sesión).
- Vos hacés: le pasás un enlace al buscador con un payload:
/buscar?q=<script>...</script> - Pasa: al abrirlo, la página imprime el <script> sin escapar y el navegador de la víctima lo ejecuta como si fuera código del sitio.
En SEGURO: la salida se escapa, el <script> se muestra como texto y la CSP no deja correr scripts inline. No pasa nada.
Riesgo: ejecutar JavaScript en el navegador de la víctima para robarle la sesión o redirigirla.
Guardar contenido de usuarios y mostrarlo a otros
Inseguro
<%- c.cuerpo %>
El comentario guardado se inyecta sin escapar: ataca a cada visitante.
Seguro
<%= c.cuerpo %>
Se escapa al mostrar. El payload queda como texto, no como código.
- Vos hacés: dejás un "comentario" en el libro de visitas cuyo texto es
<script>...</script>. Queda guardado en la base. - Pasa: cada persona que entra a la página recibe el script y lo ejecuta. No hace falta mandarle nada a nadie: el ataque queda "plantado".
En SEGURO: al mostrar el comentario se escapa, así que se ve el texto literal <script> en vez de ejecutarse.
Riesgo: un solo comentario malicioso compromete a todos los que abren la página (más grave que el reflejado).
El navegador arma HTML con datos de la URL
Inseguro
destino.innerHTML = nombre;
nombre viene de location.hash. innerHTML interpreta HTML.
Seguro
destino.textContent = nombre;
textContent nunca interpreta HTML: inserta texto plano.
- Vos hacés: compartís un enlace con el payload después del
#:/saludo#<img src=x onerror=alert(1)> - Pasa: el JavaScript de la página toma el texto del
#y lo mete coninnerHTML, que interpreta el HTML y dispara el código.
En SEGURO: se usa textContent: lo del # se inserta como texto, nunca como HTML.
Riesgo: el payload ni siquiera pasa por el servidor; se ejecuta solo en el navegador.
Que la cookie sea (o no) legible por JavaScript
Inseguro
sid=abc123; Path=/; SameSite=None // sin HttpOnly
document.cookie la lee -> el XSS la roba y la manda al atacante.
Seguro
sid=abc123; Path=/; SameSite=Lax; HttpOnly; Secure
HttpOnly: el JavaScript NO puede leerla. El robo por XSS deja de funcionar.
Riesgo: con la cookie robada, el atacante se hace pasar por la víctima (session hijacking) y lee sus notas privadas.
Robar y secuestrar la sesión, paso a paso
El servidor no pide usuario y contraseña en cada clic: identifica a la persona por el sid que viaja en la cookie. Si consigo tu sid, soy vos.
- La víctima inicia sesión en Notas. Su navegador guarda
sid=abc123. - El atacante le manda su enlace trampa (el del buscador con XSS).
- La víctima lo abre estando logueada. El script lee
document.cookiey manda elsidal atacante (endpoint/c/:token). - El
sidaparece en el panel de loot del atacante, en vivo. - El atacante toca "Secuestrar sesión".
Qué hace "Secuestrar sesión"
res.append('Set-Cookie',
`sid=${sidRobado}; Path=/; ...`);
res.redirect('/notas');
Pega el sid de la víctima como su propia cookie y entra a /notas. El server cree que es la víctima: le muestra sus notas privadas. Nunca hizo falta la contraseña.
Por qué en SEGURO falla
// cookie: HttpOnly + Secure // + CSP con nonce
El paso 3 nunca ocurre: con HttpOnly el script no puede leer document.cookie, y la CSP ni siquiera deja correr el script. El panel de loot queda vacío, no hay sid que secuestrar.
Riesgo: suplantación total de identidad (session hijacking). El atacante opera como la víctima sin conocer su clave.
Cómo se mira en Chrome y Firefox
No hace falta una app extra: se abre la caja de herramientas del propio navegador y se ve lo que viaja (cookies, cabeceras, CSP).
- Chrome: F12, o clic derecho → Inspeccionar. Firefox: F12, o clic derecho → Inspeccionar.
- Consola: se escribe
document.cookiey Enter. En modo VULNERABLE aparecesid=...(la cookie es legible por JavaScript). En modo SEGURO no aparece: está marcadaHttpOnly. - Cookies: en Chrome, pestaña Application → Storage → Cookies → el dominio. En Firefox, pestaña Almacenamiento → Cookies. La columna
HttpOnlydice si el script puede leerla.
Pestaña Network / Red
Se recarga y se filtra por login o /c/. Ahí se ve el POST del login, el GET a /c/:token cuando el XSS manda la cookie, la respuesta Set-Cookie y (en SEGURO) la cabecera Content-Security-Policy.
Qué cambia en SEGURO
document.cookie no muestra el sid. En la tabla de cookies, HttpOnly está tildado. El XSS del buscador no llega a /c/:token porque la CSP bloquea el script.
Para la demo: con DevTools abierto se muestra en vivo la diferencia entre "la cookie se puede robar" y "el navegador la esconde".
Armar la consulta SQL concatenando texto
Inseguro
const q = `SELECT * FROM usuarios
WHERE email = '${email}'
AND password_md5 = '${md5(pass)}'`;
db.prepare(q).get();
Payload en email: ' OR 1=1 -- (espacio tras --) entra sin contraseña (tautología).
Seguro
db.prepare('SELECT * FROM usuarios
WHERE email = ?').get(email);
// + bcrypt.compare + bloqueo 5 intentos
Consulta parametrizada: el dato nunca se interpreta como SQL.
- Vos hacés: en el campo email escribís
' OR 1=1 --y dejás la contraseña vacía. - Pasa: la consulta queda
... WHERE email='' OR 1=1 -- ' AND password_md5=.... ElOR 1=1es siempre verdadero y el--comenta el chequeo de contraseña, así que entras como el primer usuario.
En SEGURO: el email viaja como parámetro (?), nunca se interpreta como SQL; además valida con bcrypt y bloquea a los 5 intentos.
Riesgo: saltear el login, leer o borrar toda la base de datos.
Forzar el login en modo VULNERABLE
- El interruptor de
/adminqueda en VULNERABLE. - Se abre
/login?demo=1. - En email se pega exactamente:
' OR 1=1 --(hay un espacio después de--). La contraseña se deja vacía. - Se envía el formulario. Entra como el primer usuario, sin conocer la clave.
Por qué entra
WHERE email = '' OR 1=1 -- ' AND password_md5 = '...' // OR 1=1 es siempre verdadero // -- comenta el chequeo de la contraseña
Es una sola sentencia (tautología). .get() de SQLite no corre varias consultas, pero acá no hace falta: con una alcanza.
Qué se ve en Red
En Network/Red, el POST a /login responde 302 y un Set-Cookie: sid=.... En la Consola, document.cookie muestra ese sid si el modo sigue en VULN.
En SEGURO: el email viaja como parámetro, no como SQL. El mismo payload no entra.
Ojo: el espacio después de -- importa: en SQL el comentario -- lleva un espacio. Sin ese espacio, el AND password_md5 no se comenta y el login falla.
Pasar la entrada a un comando del sistema
Inseguro
ejecutar("ping " + host)
// host = "8.8.8.8; cat /etc/passwd"
Los metacaracteres ; | && encadenan comandos extra.
Seguro
/^(\d{1,3}\.){3}\d{1,3}$/.test(host)
// allowlist: solo IP o hostname valido
Se valida con lista blanca; cualquier metacaracter se rechaza.
- Vos hacés: en el campo "host" para hacer ping escribís
8.8.8.8; cat /etc/passwd. - Pasa: el servidor arma
ping 8.8.8.8; cat /etc/passwd. El;encadena un segundo comando y te devuelve el contenido de archivos del sistema.
En SEGURO: una lista blanca (regex) acepta solo una IP/hostname válido; cualquier ; | && se rechaza.
Riesgo: ejecutar comandos arbitrarios en el servidor con los permisos del proceso web.
Cómo se guardan las contraseñas
Inseguro
md5(password) // misma pass => mismo hash // sin salt, rapido de crackear
Vulnerable a diccionario y rainbow tables.
Seguro
bcrypt.hashSync(password, 10) // salt por usuario + lento a proposito
Misma pass => hashes distintos; ataques carísimos.
- Situación: conseguiste la base (por SQLi o por el
/backup.sqlexpuesto). Tenés los hashes MD5. - Vos hacés: buscás cada hash en una tabla precalculada (rainbow table) o lo comparás con un diccionario. MD5 es instantáneo.
En SEGURO: bcrypt es lento a propósito y cada clave tiene su propio salt, así que ni rainbow tables ni diccionarios masivos sirven.
Riesgo: si se filtra la base, con MD5 las contraseñas caen en segundos.
El código de un solo uso
Inseguro
Math.random() -> 4 digitos guardado en texto plano sin expiracion ni limite
Predecible, reutilizable, fuerza bruta (10.000 opciones).
Seguro
crypto.randomInt() -> 6 digitos se guarda SHA-256(codigo) expira 10min, un solo uso, timingSafeEqual
Hash, expiración, límite de intentos y comparación en tiempo constante.
- Vos hacés: como el código es de 4 dígitos, no expira y no limita intentos, los probás todos (0000..9999) o aprovechás que
Math.random()es predecible. - Pasa: adivinás el OTP de la víctima y completás el segundo factor sin tener su teléfono/mail.
En SEGURO: 6 dígitos con crypto.randomInt, se guarda solo el hash, expira a los 10 min, un solo uso y límite de intentos: la fuerza bruta no llega.
Hash vs cifrado: el OTP se hashea (no hace falta recuperarlo). Un secreto TOTP se cifra (AES-256-GCM) porque el servidor necesita leerlo.
Pedir un recurso por id sin verificar el dueño
Inseguro
SELECT * FROM notas WHERE id = ?
/notas?id=4 devuelve la nota privada de otra cuenta.
Seguro
SELECT * FROM notas WHERE id = ? AND usuario_id = ? // si no, 403
Deny by default: solo accedés a lo tuyo.
- Situación: entras con TU cuenta y ves tus notas en
/notas. Cada una tiene un id (la tuya es, por ejemplo, la #7). - Vos hacés: cambiás el número en la URL a mano:
/notas?id=4. - Pasa: la consulta pide la nota 4 sin verificar que sea tuya, así que te devuelve la "Nota privada del Admin". Probás id=1, 2, 3... y leés las de todos.
En SEGURO: la consulta agrega AND usuario_id = <el tuyo>. Pedir el id de otro no devuelve nada: responde 403 (denegar por defecto).
Riesgo: leer o modificar datos de otros usuarios cambiando un número en la URL. No hace falta hackear nada: solo editar la dirección.
Cabeceras de seguridad y errores
Inseguro
// sin CSP, sin X-Frame-Options X-Powered-By: Express // stack trace visible, /backup.sql expuesto
Expone tecnología, permite clickjacking y filtra info.
Seguro
Content-Security-Policy: ... 'nonce-...' X-Frame-Options: DENY X-Content-Type-Options: nosniff Strict-Transport-Security: ...
Defensa en profundidad; errores genéricos.
- Vos hacés: probás rutas típicas como
/backup.sqlo forzás un error en la app. - Pasa:
/backup.sqlte entrega usuarios y hashes, y los errores muestran el stack trace completo (rutas internas, versiones, consultas). Con eso planeás el siguiente ataque.
En SEGURO: /backup.sql da 404, los errores son genéricos y las cabeceras (CSP, X-Frame-Options, nosniff, HSTS) cierran clickjacking y fugas.
Riesgo: dar información y superficie de ataque gratis al atacante.
En resumen
- Nunca confiar en la entrada del usuario: validar y escapar.
- Consultas parametrizadas, nunca concatenar SQL.
- Cookies HttpOnly + Secure + SameSite.
- CSP y cabeceras de seguridad siempre.
- Contraseñas con bcrypt; secretos en variables de entorno.
- Verificar autorización en cada acceso (deny by default).
Probalo vos: cambia el modo en /admin y repetí cualquier ataque.