Sesión 5 — OWASP API Security Top 10 y hardening
Bloque: ORMs, APIs REST y Seguridad (última sesión de construcción; el bloque se cierra con Testing el 14 dic)
Fecha: Martes 1 de diciembre · 15:30–19:30 (4h)
Stack: Node.js ≥ 24, TypeScript, Express 5, Prisma 7, Zod 4, Helmet 8, cors, express-rate-limit 8
Repo de referencia: academia-api · partes de tu rama (rescate: origin/s5-start) · punto de control origin/s5-checkpoint · solución origin/s5-end (se destapa al cierre)
Continúa de: Sesión 4 — Autenticación y autorización
Objetivos de la sesión
Sección titulada «Objetivos de la sesión»Al terminar, el alumno debe ser capaz de:
- Explicar BOLA (API1), API3, API4 y API8 del OWASP API Security Top 10 (edición 2023): qué son, cómo se atacan y cómo se defienden; y aplicarlo auditando código real generado por IA.
- Reconocer y corregir BOLA, mass assignment y excessive data exposure en endpoints propios.
- Explicar por qué Prisma protege de inyección SQL por defecto, cuándo ese blindaje se pierde, y escribir una búsqueda segura con
$queryRaw. - Configurar rate limiting contando por la IP correcta (
trust proxy). - Explicar qué hace CORS (y qué no) y elegir con criterio dónde guarda el token el frontend (
localStoragevs cookiehttpOnly+SameSite). - Activar Helmet y ordenar los middlewares de
app.tssabiendo por qué va cada uno donde va.
Hoy formalizamos los cabos que dejamos sueltos a propósito ayer (user enumeration, BOLA, dónde guardar el token) y añadimos lo que falta para que AcademIA no sea ingenua en producción. Es la última sesión en la que construimos la API; la sesión de Testing (14 dic) cierra el bloque convirtiendo todo esto en tests automáticos.
Cómo es la sesión de hoy: después de cada tema hay un minilab de 6–12 min en el que lo aplicáis en vuestro proyecto. Todos siguen la misma plantilla:
- Título: «Demuestra que …»: lo que vas a demostrar.
- 0 · Rómpelo a propósito (solo si hace falta para ver el fallo): un cambio temporal que el paso 2 deshace.
- 1 · Antes: un comando (📋, se copia de la web) y la salida que verás ahora.
- 2 · Cambia: el fichero y el código exactos (✍️ a mano lo que enseña la idea; 📋 de la web el resto).
- 3 · Después: el mismo comando (📋) y la salida que debes ver.
- ✓ Hecho cuando: antes X → después Y.
Bloques para copiar: web del curso → Sesión 5 → Chuleta. Cada bloque lleva un ID (📋 S5·07) que también verás en la slide; lo marcado ✍️ se teclea a mano.
Los minilabs se construyen uno sobre otro, partiendo de tu rama de la sesión 4 (o de origin/s5-start). Al acabar cada uno, commit:
git add -A && git commit -m "S5 minilab N"Windows: todo en Git Bash (no PowerShell). Viene con Git y es la única terminal que usamos en el bloque: los curl multilínea, TOKEN=… y el bucle for de hoy son de bash. Las comprobaciones con GET también se pueden hacer pegando la URL en el navegador.
Tu rama y los puntos de recuperación
Sección titulada «Tu rama y los puntos de recuperación»Trabajas en tu clon de academia-api, en tu propia rama. Sigue en tu rama. Si los checks del inicio de la sesión fallan o vas tarde, usa la receta “Cambiar de rama”.
Checks del inicio: npm run seed, la API recién arrancada (npm run dev) y, en otra terminal, npx tsx checks/s4.ts → 19 ok, 0 fallos (¿otro puerto? el export API de la sección siguiente); npm run typecheck sin errores. checks/s4.ts hace varios logins: ejecútalo con la API recién arrancada (si sale un 429, reinicia la API).
El repo trae dos ramas de rescate para hoy:
origin/s5-start= final de la sesión 4: auth, roles, ownership en cursos yGET /users/:id/enrollmentsconstruido pero sin proteger. Para quien no pasa los checks del inicio, al empezar.origin/s5-checkpoint= todo lo que hacemos en clase hasta la práctica: Helmet, CORS, rate limiters,TRUST_PROXY, elselectdel instructor y la búsqueda segura. Le faltan exactamente las dos piezas de la práctica (marcadas con comentarios// Práctica:). Para quien se ha quedado atrás, antes de la práctica (§7.2).
En un clon, las ramas solo existen como remotas (origin/…): sin origin/ delante, git responde not a commit o invalid reference. Receta Cambiar de rama:
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)git fetch origingit switch -c mi-s5 origin/s5-start # checkpoint: mi-s5-cp origin/s5-checkpointnpm installnpx prisma generatenpx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seedSi el commit dice nothing to commit, no pasa nada: sigue. npm install hace falta en el checkpoint (trae helmet, cors y express-rate-limit). Hoy no hay migraciones nuevas: desde una rama del repo, la base de la sesión 4 sirve tal cual; si vienes de tu propia rama, migrate dev pedirá reset: es lo esperado (tus migraciones tienen otro timestamp); después, npm run seed. El checkpoint va siempre en su propia rama, mi-s5-cp: git switch -c mi-s5-cp origin/s5-checkpoint.
Traer un solo fichero sin cambiar de rama: git checkout origin/s5-checkpoint -- ruta/del/fichero.
Ojo con el checkpoint: si cambias a origin/s5-checkpoint a mitad de sesión con cambios sin commitear, pierdes el trabajo de los minilabs (o Git se niega a cambiar). Commit primero, siempre.
Tu terminal: una variable API para toda la sesión
Sección titulada «Tu terminal: una variable API para toda la sesión»Todos los curl de hoy usan $API, y el script de comprobación (npx tsx checks/s5.ts) lee esa misma variable API (si no existe, usa http://localhost:3000/api/v1). Defínela una vez, en la terminal (Git Bash en Windows) donde lanzas los curl:
export API=http://localhost:3000/api/v1 # export: si no, los checks no la ven- ¿El 3000 ocupado? Arranca con
PORT=3001 npm run devy defineexport API=http://localhost:3001/api/v1. - Con
exportla ven también los procesos que lances, comonpx tsx checks/s5.ts. Sin él, solo la ve la shell. Si abres otra terminal, defínela otra vez. - Las flags de
curlque se repiten hoy (-s,-i,-H,-d,-X,-D -,-o /dev/null,-w) están en la chuleta de curl y shell de la sesión 3. Las que solo salen hoy (--get --data-urlencodey el buclefor) se explican donde aparecen (§3.2 y §4).
1. Repaso: los cabos sueltos de la sesión 4
Sección titulada «1. Repaso: los cabos sueltos de la sesión 4»Tres cosas quedaron abiertas ayer:
- User enumeration:
loginya no lo revela;registersí (409). - BOLA (lo explicamos hoy, §2.2): en
GET /users/:id/enrollmentscualquier autenticado ve las inscripciones de otro. - Dónde guarda el token el frontend:
localStoragevs cookiehttpOnly.
Hoy estos tres dejan de ser “cosas que mencionamos” y pasan a tener nombre, categoría y solución concreta.
Antes de seguir (3 min): API arrancada (npm run dev) y http://localhost:3000/api/v1/courses respondiendo en el navegador. Hoy vamos a tocar app.ts, config.ts, las rutas y el repositorio de cursos; define también la variable API (arriba). Si tu proyecto no arranca o no pasa los checks del inicio, ahora es el momento de origin/s5-start (comandos arriba), no a mitad de sesión.
2. OWASP API Top 10 en AcademIA
Sección titulada «2. OWASP API Top 10 en AcademIA»Primero los conceptos (20 min), después la auditoría del código de IA (15 min), que ahora es aplicar lo que acabáis de ver, y el Minilab 1 (15 min).
2.1 Las diez categorías: el mapa
Sección titulada «2.1 Las diez categorías: el mapa»OWASP mantiene un ranking de las vulnerabilidades más críticas específicamente en APIs (distinto del OWASP Top 10 “web” general). La edición vigente sigue siendo la 2023 (no hay edición posterior del de APIs). No confundir con el OWASP Top 10 web, cuya edición vigente es la 2025: ahí BOLA cae dentro de A01 Broken Access Control, la inyección es A05 Injection y el paquete equivocado que sugiere la IA encaja en la categoría nueva A03 Software Supply Chain Failures.
| # | Categoría | En una frase |
|---|---|---|
| API1 | Broken Object Level Authorization (BOLA) | Un usuario accede/modifica datos de otro cambiando un id en la URL |
| API2 | Broken Authentication | Fallos en login, tokens, gestión de sesión |
| API3 | Broken Object Property Level Authorization | Expone o permite modificar campos que no debería (excessive data exposure + mass assignment) |
| API4 | Unrestricted Resource Consumption | Sin límites de rate/tamaño → DoS o costes disparados |
| API5 | Broken Function Level Authorization | Un endpoint de admin accesible por un usuario normal |
| API6 | Unrestricted Access to Sensitive Business Flows | Automatizar un flujo de negocio a escala (ej. comprar todo el stock con un bot) |
| API7 | Server Side Request Forgery (SSRF) | La API hace requests a URLs controladas por el atacante |
| API8 | Security Misconfiguration | CORS abierto, cabeceras ausentes, errores verbosos en producción |
| API9 | Improper Inventory Management | Endpoints viejos/de test olvidados y sin proteger |
| API10 | Unsafe Consumption of APIs | Confiar ciegamente en datos de APIs de terceros |
No hay que memorizarla: es el mapa. Hoy profundizamos en API1, API3, API4 y API8, cada una con el mismo patrón: qué es · cómo se ataca en AcademIA · cómo se defiende. API2 (Broken Authentication) ya lo trabajasteis ayer: Argon2id, login que responde igual y tarda lo mismo (hash ficticio), JWT de 15 min con algorithms: ['HS256']; le falta el rate limiting en /auth/login, que es API4 (§2.4 y §4). API5 lo cubristeis con requireRole.
2.2 API1 · BOLA (Broken Object Level Authorization)
Sección titulada «2.2 API1 · BOLA (Broken Object Level Authorization)»Qué es. Estar autenticado no es lo mismo que poder acceder a ese objeto. La API comprueba quién eres (el token es válido), pero no si ese id es tuyo. Es la nº 1 del ranking porque es la más frecuente en APIs reales: cada endpoint con un id en la ruta necesita su propia comprobación, y basta con olvidar una.
Cómo se ataca en AcademIA (pruébalo, 3 min). GET /api/v1/users/:id/enrollments sigue sin proteger. Luis (STUDENT) hace login, copia su token (solo lo que va entre comillas tras "token":) y pide las inscripciones de otro usuario:
# Luis (STUDENT) hace login y copia su tokencurl -s -H 'Content-Type: application/json' \ -d '{"email":"luis@academia.dev","password":"Passw0rd!"}' $API/auth/login✍️ A mano: TOKEN= seguido del token que acabas de copiar, sin comillas (TOKEN=eyJhbGciOi…).
# …y pide las inscripciones de OTRO usuario (ids correlativos: prueba 4, 5, 6…)curl -s -H "Authorization: Bearer $TOKEN" $API/users/6/enrollments# → 200 [{"id":2,"userId":6,"courseId":1,"status":"COMPLETED","grade":9, …# "course":{"id":1,"title":"TypeScript desde cero"}}]La salida es real (base recién sembrada desde origin/s5-start, donde Sara es el usuario 6). Los ids son correlativos: probar 4, 5, 6… es lo que haría un atacante.
Un 200 con un userId que no es el suyo: Luis ve la nota de Sara. En tu base los alumnos pueden tener otros ids (el seed los crea en paralelo): prueba ids cercanos hasta dar con la inscripción COMPLETED con "grade":9. Sin token, la API responde 401: la autenticación funciona; lo que falta es la autorización sobre ese objeto.
Cómo se defiende. En cada acceso a un objeto por id, comprobar que es tuyo (o que eres ADMIN). Ayer lo hicisteis en las escrituras de cursos (PATCH/DELETE /courses/:id) con getOwnedCourse (src/services/courses.service.ts, o como se llame en tu código):
async function getOwnedCourse(courseId: number, actor: Actor) { const course = await coursesRepository.findById(courseId); if (!course) throw new NotFoundError('Course');
const isOwner = course.instructorId === actor.id; const isAdmin = actor.role === 'ADMIN'; if (!isOwner && !isAdmin) throw new ForbiddenError('You do not own this course');
return course;}Hoy falta en una lectura: GET /users/:id/enrollments. ¿Con requireRole o con ownership? Lo cerráis vosotros en la práctica (§7.2).
2.3 API3 · Broken Object Property Level Authorization
Sección titulada «2.3 API3 · Broken Object Property Level Authorization»Qué es. El objeto sí es tuyo (o es público), pero tocas o ves propiedades que no deberías. Tiene dos caras.
Cara 1: mass assignment (el cliente escribe campos que no debería). Marta (INSTRUCTOR) edita el precio de su curso y, de paso, cuela el dueño:
curl -s -X PATCH -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{"price":1,"instructorId":2}' $API/courses/IDprice puede tocarlo; instructorId, no. Si el código hace data: req.body (pasar el body tal cual a Prisma, como la IA de §2.6), el curso cambia de dueño: 200 y "instructorId":2 (el 2 es Pablo, el otro instructor del seed), y desde ese momento Marta recibe 403 en su propio curso.
Defensa: el schema de Zod es la lista blanca de lo que el cliente puede escribir. Vuestra API ya lo tiene: validateBody(updateCourseSchema) con z.object() descarta en silencio las claves no declaradas, así que el mismo PATCH da 200, cambia el precio y el instructorId no cambia (lo demostráis en el Minilab 1). Falta que el rechazo sea visible: z.strictObject() responde 400 (la práctica, §7.2). Y los campos sensibles (instructorId, role) salen de req.user, nunca del body.
Cara 2: excessive data exposure (la API devuelve campos de más, confiando en que el frontend “no los pinte”; el JSON lo lee cualquiera). Con include: { instructor: true } en el findMany del repositorio, el listado público, sin token, devuelve esto (salida real, recortada):
curl -s "$API/courses?pageSize=1"… "instructor":{"id":1,"email":"marta@academia.dev","name":"Marta","password":"$argon2id$v=19$m=65536,t=3,p=4$xlPI…","role":"INSTRUCTOR", …}Sin login: el email y el hash de la contraseña de cada instructor. Defensa: select explícito con solo lo necesario (id, name). Lo hacéis en el Minilab 1.
2.4 API4 · Unrestricted Resource Consumption
Sección titulada «2.4 API4 · Unrestricted Resource Consumption»Qué es. La API acepta todo lo que le manden, tan rápido y tan grande como se lo manden: sin límites de peticiones, de tamaño ni de resultados. Consecuencias: fuerza bruta, caídas (también sin malicia: un cliente con un bug que reintenta en bucle) y factura disparada.
| Ataque | Hoy en AcademIA (origin/s5-start) |
Defensa |
|---|---|---|
6 logins fallidos seguidos a /auth/login (el bucle del Minilab 3) |
401 401 401 401 401 401: nadie frena |
rate limit → 429 (§4) |
GET $API/courses?pageSize=100000 |
400 "Too big: expected number to be <=50": tope desde la S3 |
paginación con tope |
| Body JSON de 50 MB | express.json() lo corta en 100 kB (su default): no llega a parsearse |
límite de body explícito, limit: '100kb' (§6.2) |
Cómo funciona un rate limiter: una ventana de tiempo y un contador por IP (o por otra clave, como el usuario). Cada petición suma; al pasar el límite dentro de la ventana, la API responde 429 Too Many Requests con cabeceras RateLimit (cuánto queda y cuándo se reinicia). Pasada la ventana, el contador vuelve a cero.
2.5 API8 · Security Misconfiguration
Sección titulada «2.5 API8 · Security Misconfiguration»Qué es. Nada falla en tu lógica: es la configuración (la de por defecto o la copiada) la que expone. Tres ejemplos típicos: CORS abierto (cualquier origen) con credenciales, cabeceras que delatan el stack y errores con trazas internas.
Cómo se ataca en AcademIA: basta mirar las cabeceras (salida real de origin/s5-start, recortada):
curl -s -D - -o /dev/null $API/coursesHTTP/1.1 200 OKX-Powered-By: ExpressContent-Type: application/json; charset=utf-8X-Powered-By: Express regala el stack a quien escanea buscando versiones vulnerables, y no hay ni una cabecera de seguridad.
Cómo se defiende: lista explícita de orígenes en CORS (§5), Helmet (§6) y errores genéricos en producción (vuestro errorHandler de la sesión 3 ya responde {"error":"Internal server error"} sin stack trace).
2.6 Ejercicio: audita lo que te ha dado la IA
Sección titulada «2.6 Ejercicio: audita lo que te ha dado la IA»Ahora lo aplicáis. Le pedimos a un asistente: “Añade a mi API: PATCH /courses/:id para editar un curso, una búsqueda de cursos por título, y configura CORS y rate limiting”. Esto es lo que devolvió, el fichero s5-patch-course.ia.ts entero (📋 S5·05 en la web: léelo allí o pégalo en un fichero aparte, fuera de src/; es para leer, no para ejecutar):
// ─────────────────────────────────────────────────────────────────────────// Generado por un asistente de IA · Sesión 5// Prompt: "Añade a mi API: PATCH /courses/:id para editar un curso, una// búsqueda de cursos por título, y configura CORS y rate limiting"// Instalación sugerida por el asistente:// npm install cors express-rate-limiter// Tu tarea: mapea cada fallo a su categoría del OWASP API Top 10 (API1…API10).// Antes de instalar nada: npm view <paquete> (autor, descargas, repo).// ─────────────────────────────────────────────────────────────────────────import cors from 'cors';import express, { type Request, type Response } from 'express';import { prisma } from '../db.js';import { requireAuth } from '../middlewares/require-auth.js';
export const app = express();app.use(cors({ origin: '*', credentials: true }));app.use(express.json());
app.patch('/api/v1/courses/:id', requireAuth, async (req: Request, res: Response) => { const course = await prisma.course.update({ where: { id: Number(req.params.id) }, data: req.body, include: { instructor: true }, }); res.json(course);});
app.get('/api/v1/courses/search', async (req: Request, res: Response) => { const results = await prisma.$queryRawUnsafe( `SELECT * FROM "Course" WHERE title ILIKE '%${req.query.q}%'`, ); res.json(results);});Tarea (por parejas, 8 min): con lo de §2.2–§2.5, ¿qué categoría (API1, API3, API4 o API8) es cada fallo? Uno de ellos no tiene categoría propia en el API Top 10: ¿cuál? Y antes de instalar nada, ejecutad vosotros:
npm view express-rate-limiternpm view express-rate-limit¿Cuál de los dos es el que queríais?
Si vuestra API habla con un LLM (mención): desde 2023 OWASP ha publicado el Top 10 for LLM Applications 2026 y el Top 10 for Agentic Applications 2026. Si vuestra API llama a un modelo o expone herramientas a un agente, son las listas a mirar (el bloque de IA Aplicada las trabaja).
Lo que nos llevamos del ejercicio: en veinte líneas, la IA ha metido fallos de cuatro categorías del Top 10, una inyección SQL de propina, y además os ha propuesto instalar el paquete equivocado. Ninguno salta al ejecutar: todo “funciona”. El resto de la sesión es convertir cada uno de esos fallos en una línea de vuestro código que lo impide, y comprobarlo.
2.7 Minilab 1 · API3 en tu proyecto
Sección titulada «2.7 Minilab 1 · API3 en tu proyecto»Las dos caras de §2.3, en vuestra API, con la plantilla de hoy. Salidas reales de un clon de origin/s5-start.
1/2 · Demuestra que el listado no regala el hash
Sección titulada «1/2 · Demuestra que el listado no regala el hash»0 · Rómpelo a propósito, para verlo (✍️ a mano). En src/repositories/courses.repository.ts, en el include de findMany, pon lo que hizo la IA:
// src/repositories/courses.repository.ts, en el include de findManyinclude: { category: true, instructor: true,},1 · Antes (o abre $API/courses?pageSize=1 en el navegador):
curl -s "$API/courses?pageSize=1"# → …"instructor":{"id":1,# "email":"marta@academia.dev",# "name":"Marta",# "password":"$argon2id$v=19$m=65536…",# "role":"INSTRUCTOR", …}2 · Cambia (✍️ a mano): select explícito, en el mismo include:
// src/repositories/courses.repository.ts, en el include de findManyinclude: { category: true, // ✅ Solo lo necesario del instructor. Con `instructor: true` saldrían // su email y el HASH de su contraseña (excessive data exposure, API3). instructor: { select: { id: true, name: true } },},3 · Después, el mismo curl:
curl -s "$API/courses?pageSize=1"# → …"instructor":{"id":1,"name":"Marta"}}]✓ Hecho cuando: antes "password":"$argon2id…" → después "instructor":{"id":1,"name":"Marta"}.
2/2 · Demuestra que el body no cambia el dueño
Sección titulada «2/2 · Demuestra que el body no cambia el dueño»Primero, login de Marta (INSTRUCTOR) como en el ataque BOLA (§2.2): copia el token de la respuesta (solo lo que va entre comillas tras "token":; la respuesta sale sin salto de línea, pegada al prompt) y apunta el id del primer curso del listado (es suyo):
curl -s -H 'Content-Type: application/json' \ -d '{"email":"marta@academia.dev","password":"Passw0rd!"}' \ $API/auth/login✍️ A mano, con tus valores: TOKEN= + el token (sin comillas) e ID= + el id de ese curso (en una base recién sembrada, ID=1). Los curl de abajo usan $TOKEN y $ID.
0 · Rómpelo a propósito (= el data: req.body de la IA) (✍️ a mano). En src/routes/courses.routes.ts, quita validateBody(updateCourseSchema) del PATCH: el body llega tal cual a prisma.course.update:
// src/routes/courses.routes.ts: el PATCH, SIN validateBody (temporal)coursesRouter.patch('/:id', requireAuth, requireRole('INSTRUCTOR', 'ADMIN'), coursesController.update);1 · Antes:
curl -i -X PATCH \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{"price":20,"instructorId":999}' \ $API/courses/$ID# → 409 {"error":"Foreign key constraint failed…"}La respuesta completa es HTTP/1.1 409 Conflict con {"error":"Foreign key constraint failed (related resource missing or still referenced)"}.
El 999 ha llegado hasta la base de datos, que lo rechaza solo porque ese usuario no existe. Con un id real (el 2, Pablo) el curso cambiaría de dueño de verdad (§2.3): no lo pruebes, perderías tu curso y npm run seed no lo devuelve.
2 · Cambia (✍️ a mano): deja la ruta bien, con el schema:
coursesRouter.patch('/:id', requireAuth, requireRole('INSTRUCTOR', 'ADMIN'), validateBody(updateCourseSchema), coursesController.update);3 · Después, el mismo curl (📋 S5·10):
HTTP/1.1 200 OK{"id":1,"title":"TypeScript desde cero", … "price":"20", … "instructorId":1,"categoryId":2}✓ Hecho cuando: antes 409 (el campo llega a la base de datos) → después 200 con "instructorId":1.
Lo que acabáis de ver. Una afirmación típica de los apuntes de seguridad dice “z.object() deja pasar campos inventados”. Es falsa: z.object() ya descarta en silencio las claves no declaradas, y validateBody sustituye req.body por el resultado validado; por eso el precio cambia y el dueño no. El peligro real es saltarse el schema (data: req.body, como la IA) o pedir expresamente que deje pasar todo (z.looseObject(), el antiguo .passthrough()). Lo que falta es que el cliente se entere de que su instructorId se ha ignorado: z.strictObject() lo convierte en un 400. Es una de las dos piezas de la práctica (§7.2).
3. Inyección: Prisma y $queryRaw
Sección titulada «3. Inyección: Prisma y $queryRaw»El mismo patrón que en §2: qué es, cómo se ataca, cómo se defiende.
Qué es. El input del usuario acaba dentro del SQL y cambia la consulta. En el API Top 10 2023 no tiene número propio (cae en los fallos generales; en la edición de 2019 era API8); el OWASP Top 10 general (web), cuya edición vigente es la 2025, sí la mantiene como categoría propia (A05:2025 Injection).
Cómo se ataca. La búsqueda de la IA (§2.6) mete q dentro del string. Con q = ' OR '1'='1, la consulta queda así:
SELECT * FROM "Course" WHERE title ILIKE '%' OR '1'='1%'ILIKE '%' casa con todo: devuelve todos los cursos, también el que no está publicado (SQL para backend). En clase se lee, no se ejecuta.
Cómo se defiende. Que el valor viaje como parámetro, separado del SQL. Con el Prisma Client normal estáis a salvo: cada método parametriza solo, aunque el valor venga directo del usuario (src/services/auth.service.ts):
const user = await prisma.user.findUnique({ where: { email: input.email } });En SQL crudo, regla para la clase: $queryRaw (template tag) parametriza; $queryRawUnsafe (string plano) NO. Si veis Unsafe en un método de una librería, es la librería diciéndoos “aquí la responsabilidad es tuya”.
3.1 Live coding: GET /api/v1/courses/search?q=
Sección titulada «3.1 Live coding: GET /api/v1/courses/search?q=»✍️ A mano: el repositorio (src/repositories/courses.repository.ts), con el template tag $queryRaw, que envía cada ${} como parámetro, nunca concatenado. Es lo que enseña la idea: lo escribimos juntos.
// 1) Arriba del fichero, tras los imports y FUERA del objeto coursesRepository:// En LIKE, % y _ son comodines: sin escaparlos, q="%" devolvería todo.// (No es inyección SQL —el valor va parametrizado—, pero sí un filtro que no filtra.)const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => '\\' + c);
// 2) DENTRO de coursesRepository, después de findMany:// SQL crudo con $queryRaw (template tag): los ${} se envían como parámetros,// nunca se concatenan. $queryRawUnsafe con un string construido = inyección SQL.search: (q: string) => prisma.$queryRaw<{ id: number; title: string }[]>` SELECT id, title FROM "Course" WHERE published = true AND title ILIKE ${'%' + escapeLike(q) + '%'} ORDER BY id LIMIT 20`,El resto de capas, ya conocidas de la sesión 3: cópialas de la web y las leemos juntos, una por fichero.
// src/schemas/course.schema.ts (añade, junto a listCoursesQuerySchema)export const searchQuerySchema = z.object({ q: z.string().trim().min(2).max(50),});// src/services/courses.service.ts (dentro de coursesService)searchCourses: (q: string) => coursesRepository.search(q),// src/controllers/courses.controller.ts (dentro de coursesController)search: async (req: Request, res: Response) => { const { q } = searchQuerySchema.parse(req.query); res.status(200).json(await coursesService.searchCourses(q));},✍️ A mano, en ese mismo controlador: añade searchQuerySchema al import que ya tienes de ../schemas/course.schema.js (queda import { idParamSchema, listCoursesQuerySchema, searchQuerySchema } from '../schemas/course.schema.js';).
// src/routes/courses.routes.ts: ANTES de la ruta '/:id'coursesRouter.get('/search', coursesController.search);La ruta va antes de /:id: si no, Express interpretaría search como un id (→ 400 de validación). Fijaos también en lo que la búsqueda no hace: columnas explícitas en vez de SELECT *, solo publicados, y LIMIT 20 (API4).
3.2 Minilab 2 · Demuestra que tu búsqueda solo busca texto
Sección titulada «3.2 Minilab 2 · Demuestra que tu búsqueda solo busca texto»--get --data-urlencode "q=…": curl codifica el texto para la URL (comillas, espacios, %) y lo añade como ?q=… a una petición GET; sin --get, lo mandaría como body de un POST. En el navegador: …/courses/search?q=%27%20OR%20%271%27%3D%271 y …/courses/search?q=%25%25.
0 · Rómpelo a propósito, para verlo (✍️ a mano): quita escapeLike de search:
// src/repositories/courses.repository.ts, en search (temporal)AND title ILIKE ${'%' + q + '%'}1 · Antes:
curl -s --get --data-urlencode "q=' OR '1'='1" \ $API/courses/search# → []curl -s --get --data-urlencode "q=%%" \ $API/courses/search# → [{"id":1,"title":"TypeScript desde cero"},# {"id":2,"title":"Prisma avanzado"}]2 · Cambia (✍️ a mano): vuelve a poner escapeLike:
// src/repositories/courses.repository.ts, en searchAND title ILIKE ${'%' + escapeLike(q) + '%'}3 · Después, los mismos curl y uno normal:
curl -s --get --data-urlencode "q=' OR '1'='1" \ $API/courses/search# → []curl -s --get --data-urlencode "q=%%" \ $API/courses/search# → []curl -s "$API/courses/search?q=Type"# → [{"id":1,"title":"TypeScript desde cero"}]✓ Hecho cuando: q=%% antes devuelve 2 cursos → después [] (y q=Type sigue encontrando TypeScript desde cero).
Lo que acabáis de ver: el payload de inyección da [] con y sin escapeLike: $queryRaw lo manda como parámetro y se busca literalmente ese texto en los títulos. El %% no es inyección: es que % y _ son comodines dentro de LIKE, y eso lo arregla escapeLike. Parametrizar evita la inyección, pero no te libra de entender el lenguaje en el que va el parámetro.
4. Rate limiting
Sección titulada «4. Rate limiting»API4 — Unrestricted Resource Consumption (qué es y cómo se ataca: §2.4). Aquí, la defensa de la primera fila de aquella tabla: el login que aguantaba seis fallos seguidos (el API2 pendiente de ayer). Un rate limiter es una ventana de tiempo + un contador por IP: al pasar el límite, 429.
Minilab 3 · Demuestra que el login frena la fuerza bruta (10 min). Empieza aquí, antes de escribir nada, y termina en §4.3.
1 · Antes, ahora: seis logins fallidos seguidos (for i in 1 2 3 4 5 6; do … done repite el curl seis veces; -o /dev/null -w "%{http_code}\n" tira el cuerpo y deja solo el código de estado, ver la chuleta de la S3):
for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code}\n" \ -H 'Content-Type: application/json' \ -d '{"email":"marta@academia.dev","password":"mala"}' \ $API/auth/logindone# → 401 401 401 401 401 401 (nadie frena)Instalad ya todo lo de hoy (2 min):
npm view express-rate-limit# el oficial, no "express-rate-limiter"npm install express-rate-limit helmet corsnpm install -D @types/cors2 · Cambia: lo de §4.1 y §4.2: src/middlewares/rate-limit.ts (nuevo), src/routes/auth.routes.ts, src/config.ts, src/app.ts y .env.example. Casi todo se copia de la web (📋); se escriben a mano (✍️) el loginRateLimiter y TRUST_PROXY. 3 · Después: en §4.3.
4.1 Los tres limitadores
Sección titulada «4.1 Los tres limitadores»src/middlewares/rate-limit.ts (fichero nuevo; express-rate-limit 8). Empieza copiando la cabecera del fichero:
// src/middlewares/rate-limit.ts (fichero nuevo)import { rateLimit } from 'express-rate-limit';import { config } from '../config.js';
// En tests no limitamos: Supertest hace todas las peticiones desde la misma IP// y el contador en memoria se comparte entre tests.const skip = () => config.NODE_ENV === 'test';✍️ A mano, debajo: el limitador del login, el que enseña la idea (ventana, límite y qué cuenta):
export const loginRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutos limit: 5, // 5 intentos fallidos por IP en la ventana skipSuccessfulRequests: true, // un login correcto no gasta intento standardHeaders: 'draft-8', legacyHeaders: false, message: { error: 'Too many login attempts, try again later' }, skip,});Los otros dos siguen el mismo patrón: cópialos debajo.
// sigue en src/middlewares/rate-limit.ts// Registro: el 409 de "email ya registrado" permite enumerar// usuarios; limitar los intentos hace esa enumeración lenta y cara.export const registerRateLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hora limit: 10, standardHeaders: 'draft-8', legacyHeaders: false, message: { error: 'Too many sign-ups from this IP, try again later', }, skip,});// sigue en src/middlewares/rate-limit.tsexport const generalRateLimiter = rateLimit({ windowMs: 60 * 1000, limit: 100, standardHeaders: 'draft-8', legacyHeaders: false, skip,});limit(antesmax, que veréis en tutoriales viejos) ystandardHeaders: 'draft-8': el cliente recibe las cabecerasRateLimit-Policy/RateLimitestándar.skipSuccessfulRequests: solo cuentan los fallos; un usuario legítimo que entra a la primera no gasta intentos.registerRateLimiter: cierra el cabo suelto de la sesión 4 — el409de registro revela emails existentes, y con 10 intentos por hora la enumeración deja de salir a cuenta.skipenNODE_ENV=test: el 14 de diciembre los tests harán muchos logins seguidos desde la misma IP; sin esto, la suite empezaría a recibir429.
Aplicación: estricto en login y registro (src/routes/auth.routes.ts, el fichero entero: sustituye el tuyo) y general para toda la API (src/app.ts, antes de express.json(): en §4.2, junto con trust proxy):
// src/routes/auth.routes.ts (completo): limitadores delante de validateBodyimport { Router } from 'express';import { authController } from '../controllers/auth.controller.js';import { loginRateLimiter, registerRateLimiter } from '../middlewares/rate-limit.js';import { validateBody } from '../middlewares/validate.js';import { loginSchema, registerSchema } from '../schemas/auth.schema.js';
export const authRouter = Router();
authRouter.post('/register', registerRateLimiter, validateBody(registerSchema), authController.register);authRouter.post('/login', loginRateLimiter, validateBody(loginSchema), authController.login);4.2 trust proxy: contar por la IP correcta
Sección titulada «4.2 trust proxy: contar por la IP correcta»El limitador cuenta por IP (req.ip). En producción la API suele estar detrás de un proxy o load balancer: sin decirle a Express que confíe en él, todas las peticiones parecen venir de la IP del proxy y el límite bloquearía a todo el mundo a la vez. Por eso src/config.ts añade, dentro de envSchema (✍️ a mano):
// src/config.ts: dentro de envSchema// Nº de proxies delante de la API (load balancer, nginx…). 0 en local.// Sin esto, detrás de un proxy todas las peticiones parecen venir de la misma IP// y el rate limit bloquearía a todo el mundo a la vez.TRUST_PROXY: z.coerce.number().int().min(0).default(0),y app.ts lo aplica, antes que cualquier otro middleware, con app.set('trust proxy', config.TRUST_PROXY). Un número de saltos, no true: con true cualquier cliente podría inventarse su IP mandando X-Forwarded-For. La propia librería avisa en consola si llega X-Forwarded-For sin trust proxy configurado.
src/app.ts con lo nuevo (dos imports, trust proxy y el límite general), el fichero entero: sustituye el tuyo:
// src/app.ts (completo tras el Minilab 3: sustituye el tuyo)import express from 'express';import { config } from './config.js';import { errorHandler } from './middlewares/error-handler.js';import { generalRateLimiter } from './middlewares/rate-limit.js';import { authRouter } from './routes/auth.routes.js';import { coursesRouter } from './routes/courses.routes.js';import { usersRouter } from './routes/users.routes.js';
// La app se exporta sin arrancar: server.ts hace listen() y los tests// (sesión 6) la usan directamente con Supertest.export const app = express();
app.set('trust proxy', config.TRUST_PROXY); // tras express()
app.use('/api/v1', generalRateLimiter); // antes de express.json()app.use(express.json());
app.use('/api/v1/auth', authRouter);app.use('/api/v1/courses', coursesRouter);app.use('/api/v1/users', usersRouter);
app.use(errorHandler); // siempre el últimoDocumenta la variable en .env.example (el .env no hace falta tocarlo: el default es 0):
# Nº de proxies delante de la API (0 en local; 1 detrás de un load balancer)TRUST_PROXY=0Mención breve: con varias instancias del servidor, el contador en memoria no se comparte; haría falta un store compartido (Redis). Basta con saber que existe la limitación.
4.3 Minilab 3 (cont.) · Demuestra que el login frena la fuerza bruta
Sección titulada «4.3 Minilab 3 (cont.) · Demuestra que el login frena la fuerza bruta»2 · Cambia (lo de §4.1 y §4.2, repaso):
src/middlewares/rate-limit.ts(nuevo): 📋 S5·19, ✍️loginRateLimiter, 📋 S5·20 y 📋 S5·21.src/routes/auth.routes.ts: 📋 S5·22 (el import yloginRateLimiter/registerRateLimiterdelante devalidateBody).src/config.ts: ✍️TRUST_PROXYenenvSchema;.env.example: 📋 S5·24.src/app.ts: 📋 S5·23 (los imports deconfigygeneralRateLimiter,app.set('trust proxy', config.TRUST_PROXY)justo después deconst app = express()yapp.use('/api/v1', generalRateLimiter)justo antes deexpress.json()).
3 · Después: el mismo bucle del «antes»:
for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code}\n" \ -H 'Content-Type: application/json' \ -d '{"email":"marta@academia.dev","password":"mala"}' \ $API/auth/logindone# → 401 401 401 401 401 429Y uno más con curl -i (cabeceras y cuerpo; salida recortada):
curl -i -H 'Content-Type: application/json' \ -d '{"email":"marta@academia.dev","password":"mala"}' \ $API/auth/login# → HTTP/1.1 429 Too Many Requests# RateLimit-Policy: "5-in-15min"; q=5; …# RateLimit: "5-in-15min"; r=0; t=900# {"error":"Too many login attempts, try again later"}(Salen también un RateLimit y un RateLimit-Policy con "100-in-1min": son del límite general.)
Depende del orden: el 429 llega en el 6º fallo desde que arrancó la API (el contador vive en memoria; guardar cualquier fichero de src/ con tsx watch también la reinicia y lo pone a cero). Si te sale antes, ya habías fallado logins desde el último arranque: reinicia y repite. Con la IP bloqueada, también un login correcto da 429.
✓ Hecho cuando: antes 401 ×6 → después cinco 401 y un 429. Después, reinicia la API (Ctrl+C y npm run dev): si no, tu IP no puede hacer login durante 15 min (y checks/s5.ts se para).
5. CORS y dónde guardar el token
Sección titulada «5. CORS y dónde guardar el token»5.1 Qué hace CORS (y qué no)
Sección titulada «5.1 Qué hace CORS (y qué no)»Los navegadores aplican la same-origin policy: JavaScript en http://localhost:5173 puede enviar una petición a http://localhost:3000, pero no puede leer la respuesta salvo que el servidor lo autorice con cabeceras. CORS es ese mecanismo de autorización de lectura cross-origin.
Tres malentendidos muy comunes:
- CORS no impide que la petición llegue al servidor. Controla si el JS de otro origen puede leer la respuesta. (Con preflight, el navegador no manda la petición real si el
OPTIONSno lo autoriza, pero las peticiones “simples” sí se envían y se ejecutan.) - CORS no es autenticación.
curlo Postman ni lo miran: no hay navegador de por medio. Quién puede hacer qué lo decidenrequireAuth,requireRoley el ownership. - CORS no es protección anti-CSRF. Un formulario de una web maliciosa puede enviar un
POSTa tu API; si la autenticación viaja sola (cookies), la acción se ejecuta aunque el atacante no pueda leer la respuesta.
En AcademIA, la lista de orígenes sale de la configuración, no del código (src/config.ts, dentro de envSchema):
// Orígenes del frontend autorizados por CORS, separados por comasCORS_ORIGINS: z.string().default('http://localhost:5173').transform((s) => s.split(',').map((o) => o.trim())),import cors from 'cors'; // import por defecto, sin llavesapp.use(cors({ origin: config.CORS_ORIGINS }));No ponemos credentials: true porque AcademIA no usa cookies: el token va en la cabecera Authorization. Y lo de la IA, origin: '*' con credentials: true, es una contradicción: el navegador rechaza el comodín cuando hay credenciales.
Preflight: con Authorization o Content-Type: application/json, el navegador manda antes un OPTIONS preguntando “¿me dejas?”. El middleware cors lo responde solo.
5.2 Minilab 4 · Demuestra que solo tu frontend lee tu API
Sección titulada «5.2 Minilab 4 · Demuestra que solo tu frontend lee tu API»«Leer» = el JavaScript de una web, en el navegador. -D - imprime las cabeceras de la respuesta y -o /dev/null tira el cuerpo (chuleta de la S3).
1 · Antes (aún sin cors):
curl -s -D - -o /dev/null \ -H "Origin: http://localhost:5173" \ $API/courses# → HTTP/1.1 200 OK# X-Powered-By: Express# RateLimit: "100-in-1min"; …# (ni rastro de Access-Control-Allow-Origin)2 · Cambia (3 ficheros):
// src/config.ts: dentro de envSchemaCORS_ORIGINS: z.string() .default('http://localhost:5173') .transform((s) => s.split(',').map((o) => o.trim())),# .env.example: añade la línea (el .env no se toca: es el default)CORS_ORIGINS="http://localhost:5173"✍️ A mano, en src/app.ts (dos líneas: la que enseña la idea es la del origin):
// src/app.ts: con los demás imports (por defecto, sin llaves)import cors from 'cors';// … y justo ANTES de la línea app.use('/api/v1', generalRateLimiter):app.use(cors({ origin: config.CORS_ORIGINS }));(config ya está importado en app.ts desde el Minilab 3.)
3 · Después: el mismo curl y dos más:
curl -s -D - -o /dev/null -H "Origin: http://localhost:5173" $API/courses# → HTTP/1.1 200 OK · Access-Control-Allow-Origin: http://localhost:5173 · Vary: Origincurl -s -D - -o /dev/null -H "Origin: https://evil.example" $API/courses# → HTTP/1.1 200 OK · Vary: Origin (sin Access-Control-Allow-Origin)curl -s -H "Origin: https://evil.example" $API/courses# → [{"id":1,"title":"TypeScript desde cero", … }] ← SÍ: los cursosEl tercero sí recibe los cursos, y es lo esperado: CORS lo aplica el navegador. El servidor responde igual a todos y solo añade (o no) la cabecera Access-Control-Allow-Origin; es el navegador el que decide si el JS de evil.example puede leer la respuesta. curl no es un navegador: ni la mira. Es el malentendido 2 en vivo: quién puede hacer qué lo deciden requireAuth, requireRole y el ownership.
✓ Hecho cuando: antes ninguna respuesta trae Access-Control-Allow-Origin → después 5173 sí y evil.example no (y curl recibe los cursos igual).
5.3 El cabo suelto de ayer: ¿localStorage o cookie httpOnly?
Sección titulada «5.3 El cabo suelto de ayer: ¿localStorage o cookie httpOnly?»Token en localStorage + cabecera Authorization |
Token en cookie httpOnly |
|
|---|---|---|
| XSS (script inyectado en el frontend) | Puede leer y robar el token y usarlo desde fuera | No puede leerla, aunque sí hacer peticiones desde la página mientras esté abierta |
| CSRF (otra web dispara la petición) | Resistente: la cabecera no viaja sola, la pone vuestro JS | Vulnerable por defecto: la cookie viaja automáticamente → hace falta SameSite (Lax/Strict) y, para ir sobrados, un token CSRF |
| CORS | Lista de orígenes, sin credentials |
Lista de orígenes y credentials: true (nunca *) |
No hay respuesta universal: con cookie el riesgo principal es CSRF y se mitiga con SameSite + token CSRF; con localStorage el riesgo es XSS y se mitiga no teniendo XSS (escapado, CSP en el frontend, cuidado con dependencias). AcademIA usa cabecera Bearer; si el frontend pasara a cookies, habría que añadir SameSite, CSRF y credentials: true.
6. Helmet y el app.ts completo
Sección titulada «6. Helmet y el app.ts completo»API8 — Security Misconfiguration (§2.5) incluye la ausencia de cabeceras HTTP de seguridad básicas y el X-Powered-By: Express que visteis allí. Helmet las ajusta con una línea: app.use(helmet()).
Qué notaréis de verdad en una API JSON (es lo que comprueba checks/s5.ts):
| Cabecera | Qué hace |
|---|---|
X-Content-Type-Options: nosniff |
El navegador no “adivina” el tipo de contenido ni ejecuta como script algo que no lo es |
Sin X-Powered-By: Express |
No regalamos el stack a quien escanea buscando versiones vulnerables |
El resto, con matices: Strict-Transport-Security (forzar HTTPS) no tiene efecto en http://localhost, solo cuenta servido por HTTPS en producción; X-Frame-Options y Content-Security-Policy protegen sobre todo páginas HTML, y la CSP por defecto puede romper Swagger UI si algún día la servís desde la propia API (habría que ajustarla para esa ruta, no quitar Helmet).
6.1 Minilab 5 · Demuestra que tu API ya no delata su stack
Sección titulada «6.1 Minilab 5 · Demuestra que tu API ya no delata su stack»1 · Antes (aún sin Helmet; o en el navegador: DevTools → Network → la petición → Headers):
curl -s -D - -o /dev/null $API/courses# → HTTP/1.1 200 OK# X-Powered-By: Express# Vary: Origin# RateLimit: "100-in-1min"; …# (sin X-Content-Type-Options)2 · Cambia: src/app.ts entero, con este orden exacto: cópialo de la web y sustituye el tuyo (lo nuevo: import helmet, app.use(helmet()) y limit: '100kb'; el resto ya lo tenías, solo se reordena; el porqué de cada posición, en §6.2):
// src/app.ts (completo: sustituye el tuyo)import cors from 'cors';import express from 'express';import helmet from 'helmet';import { config } from './config.js';import { errorHandler } from './middlewares/error-handler.js';import { generalRateLimiter } from './middlewares/rate-limit.js';import { authRouter } from './routes/auth.routes.js';import { coursesRouter } from './routes/courses.routes.js';import { usersRouter } from './routes/users.routes.js';
// La app se exporta sin arrancar: server.ts hace listen() y los tests// (sesión 6) la usan directamente con Supertest.export const app = express();
app.set('trust proxy', config.TRUST_PROXY);
// El orden importa: cabeceras → CORS → límite → parseo del body → rutas → erroresapp.use(helmet());app.use(cors({ origin: config.CORS_ORIGINS }));app.use('/api/v1', generalRateLimiter);app.use(express.json({ limit: '100kb' }));
app.use('/api/v1/auth', authRouter);app.use('/api/v1/courses', coursesRouter);app.use('/api/v1/users', usersRouter);
app.use(errorHandler); // siempre el último3 · Después, el mismo curl (recortado):
curl -s -D - -o /dev/null $API/courses# → HTTP/1.1 200 OK# Content-Security-Policy: default-src 'self';base-uri 'self';…# Cross-Origin-Opener-Policy: same-origin# Referrer-Policy: no-referrer# Strict-Transport-Security: max-age=31536000; includeSubDomains# X-Content-Type-Options: nosniff# X-Frame-Options: SAMEORIGIN# …# (X-Powered-By: Express ya no sale)✓ Hecho cuando: antes X-Powered-By: Express → después sin X-Powered-By y con X-Content-Type-Options: nosniff.
6.2 El orden de los middlewares
Sección titulada «6.2 El orden de los middlewares»Así queda src/app.ts (§6.1), por este motivo:
-
trust proxyantes que nada: el limitador necesita la IP correcta. -
Helmet y CORS primero: sus cabeceras salen también en las respuestas de error y en los
429, y el preflightOPTIONSse responde sin pasar por el resto. -
El limitador antes de parsear el body: una petición rechazada no gasta CPU en
JSON.parse. -
express.json({ limit: '100kb' }): también es API4, un body de 50 MB no llega a parsearse. Para que responda413y no un500genérico, elerrorHandlerañade su caso, junto al de JSON mal formado (✍️ a mano):src/middlewares/error-handler.ts if ((err as { type?: string }).type === 'entity.too.large') {return res.status(413).json({ error: 'Payload too large' });} -
errorHandlersiempre el último.
7. Checklist y práctica final
Sección titulada «7. Checklist y práctica final»7.1 Checklist de seguridad pre-producción
Sección titulada «7.1 Checklist de seguridad pre-producción»La checklist de todo el bloque, incluida la gestión de secretos (en el deck, una sola slide con las 16 líneas). Autoauditoría (6 min): con vuestro proyecto abierto, marcad qué tenéis ya; lo que falte de la parte de identidad y datos es justo la práctica.
- Contraseñas hasheadas con Argon2id (bcrypt solo en sistemas legados; nunca texto plano)
- JWT con expiración corta,
algorithmsfijado enjwt.verifyy secreto fuerte validado al arrancar - Todas las rutas mutables protegidas con
requireAuth - Autorización por rol (
requireRole) donde corresponde - Ownership en recursos de un usuario, comparando números y con bypass de
ADMIN(BOLA cubierto) - Schemas con
z.strictObject()y campos sensibles (instructorId,role) asignados desdereq.user, nunca desde el body (mass assignment) -
selectexplícito en relaciones: nunca sale el hash ni el email ajeno (excessive data exposure) - Rate limiting en
/auth/loginy general en la API, contrust proxycorrecto - Límite de tamaño del body y
LIMIT/paginación en listados - CORS con lista explícita de orígenes; nunca
*con credenciales - Helmet activado
- Ni
$queryRawUnsafeniPrisma.rawcon input de usuario; identificadores dinámicos solo por lista blanca - Dependencias verificadas con
npm viewantes de instalar (sobre todo si las sugiere una IA) -
.envfuera del control de versiones; secretos distintos por entorno; si uno se filtra, se rota ya - Nada de
.env, secretos ni datos de clientes en un chat de IA - Errores en producción sin stack traces ni detalles internos
7.2 Práctica guiada: hardening final de AcademIA
Sección titulada «7.2 Práctica guiada: hardening final de AcademIA»Punto de partida: tu rama con los cinco minilabs. Si te has quedado atrás, receta «Cambiar de rama» (comandos arriba) con origin/s5-checkpoint en lugar de origin/s5-start:
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)git fetch origingit switch -c mi-s5-cp origin/s5-checkpointnpm installnpx prisma generatenpx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seedEl checkpoint tiene todo lo de hoy menos las dos piezas de la práctica, marcadas con // Práctica:. Si sigues en tu rama (o vienes de origin/s5-start), el script de comprobación aún no está: tráetelo (y, con la API arrancada, lánzalo):
# en tu rama el script aún no está: tráetelogit checkout origin/s5-checkpoint -- checks/s5.tsnpx tsx checks/s5.ts # lee la misma API que tus curlEnunciado:
-
BOLA en
GET /api/v1/users/:id/enrollments(src/controllers/enrollments.controller.ts): solo el propio usuario o unADMIN; cualquier otro,403. Es la defensa de §2.2: al acabar, elcurlde Luis pidiendo las de Sara debe dar403.ForbiddenErrorestá ensrc/errors/app-error.ts; el controlador aún no lo importa:import { ForbiddenError } from '../errors/app-error.js';. -
Mass assignment visible (API3, §2.3):
createCourseSchema(src/schemas/course.schema.ts) pasa dez.object()az.strictObject().updateCourseSchemasigue siendo su.partial(), así que elPATCHdel Minilab 1 pasa de200(descarte silencioso) a400. -
Comprobación completa, con la API arrancada y el seed ejecutado (lee la misma variable
APIque tuscurl):Ventana de terminal npx tsx checks/s5.ts
Criterio de “hecho”: npx tsx checks/s5.ts termina en 16 ok, 0 fallos. Comprueba Helmet (nosniff, sin X-Powered-By), CORS (origen permitido sí, https://evil.example no), PATCH con instructorId → 400 y válido → 200, el listado sin password, BOLA (Luis → las suyas 200, Luis → las de Sara 403, ADMIN → las de Sara 200), la búsqueda con ' OR '1'='1 → [] y con %% → [], y el sexto login fallido → 429. Desde el checkpoint, antes de tocar nada, da 14 ok, 2 fallos: exactamente los dos puntos de la práctica.
Una vez por arranque de la API: el último bloque agota los intentos de login de tu IP durante 15 min. Para repetirlo, reinicia la API (Ctrl+C y npm run dev; el contador vive en memoria). Si lo ejecutas otra vez sin reiniciar (o después del Minilab 3 sin reiniciar), el script se para con:
El limitador de login te ha bloqueado (429): reinicia la API (Ctrl+C y npm run dev) y vuelve a ejecutar.Si un ✗ sale, lee el mensaje: el script imprime lo que esperaba y lo que llegó.
8. Cierre y puente a Testing
Sección titulada «8. Cierre y puente a Testing»Hoy terminamos de construir y asegurar AcademIA. Recorrido rápido de las 5 sesiones, pedido a los propios alumnos (uno por sesión, en orden):
- Sesión 1: ¿qué es un ORM y qué resuelve Prisma?
- Sesión 2: ¿cuándo una relación N:M necesita modelo explícito, y qué es el problema N+1?
- Sesión 3: ¿qué gana un proyecto con la arquitectura en capas?
- Sesión 4: ¿diferencia entre autenticación y autorización, y entre RBAC y ownership?
- Sesión 5 (hoy): una categoría del OWASP API Top 10: qué es, cómo se ataca y cómo se defiende.
La continuación: Testing Backend (14 dic). Hoy hemos comprobado la seguridad con curl y con checks/s5.ts; el día 14 esas mismas comprobaciones (CRUD, auth, ownership, BOLA, mass assignment) se convierten en tests automáticos con Vitest y Supertest, que se ejecutan solos cada vez que cambiáis algo. La arquitectura en capas de la sesión 3 (app.ts sin listen()) es lo que permite testear sin levantar un servidor, y el skip del rate limiter es lo que evitará que esos tests reciban 429. Nada de lo de hoy se tira: es lo que vamos a testear.