← Servidor web y API con Node

Sesión 5

Listo para publicar

Tres horas. Media hora para ver qué separa un proyecto de clase de un servicio expuesto a internet, y dos horas y media configurando, protegiendo y probando el tuyo.

Hoy · Hoja de ruta

  1. 1. Aprende: Cómo se configura una aplicación para varios entornos, qué protecciones no pueden faltar y cómo se escribe una prueba automática con lo que trae Node.
  2. 2. Haz: Centraliza la configuración, aplica las protecciones y escribe la batería de pruebas de tu recurso.
  3. 3. Comprueba: La aplicación no arranca si falta algo imprescindible, resiste tus propios ataques y npm test se pone en rojo cuando rompes una regla.

Antes de empezar · 5 minutos, sin apuntes

  1. ¿Qué cambiaría entre tu portátil y un servidor real, y qué debe pasar si falta una variable obligatoria?
  2. ¿Qué podría hacer alguien con acceso a tu API tal como está hoy, si envía un cuerpo de cien megabytes o llama mil veces por segundo?
  3. ¿Cómo sabes hoy que un cambio no ha roto otra cosa?

Se explica

25 minutos · conceptos y demostración

Tu aplicación funciona en tu portátil. Lo que falta para que funcione en otro sitio, delante de gente que no eres tú, se reduce a tres cosas: saber de dónde salen sus valores, soportar a quien la use mal y poder cambiarla sin romperla en silencio.

Toda la configuración, en un módulo

// src/configuracion.js
function obligatoria(nombre) {
  const valor = process.env[nombre];
  if (!valor) {
    console.error(`Falta la variable de entorno ${nombre}`);
    process.exit(1);
  }
  return valor;
}

export const configuracion = {
  puerto: Number(process.env.PUERTO ?? 3000),
  entorno: process.env.NODE_ENV ?? "development",
  rutaDatos: process.env.RUTA_DATOS ?? "./datos/productos.json",
  origenesPermitidos: (process.env.ORIGENES ?? "").split(",").filter(Boolean),
  claveApi: obligatoria("CLAVE_API")
};

Fallar al arrancar es mejor que fallar a las tres horas

Si falta una variable imprescindible, el peor comportamiento posible es arrancar como si nada y romperse cuando alguien use la funcionalidad que la necesitaba: entonces el fallo aparece lejos de su causa.

Comprobarlo todo al arrancar convierte un fallo intermitente en un mensaje claro, antes de que nadie llegue a usar la aplicación.

En la UD4 sufriste CORS como cliente. Hoy lo decides tú:

app.use((peticion, respuesta, next) => {
  const origen = peticion.headers.origin;
  if (origen && configuracion.origenesPermitidos.includes(origen)) {
    respuesta.setHeader("Access-Control-Allow-Origin", origen);
    respuesta.setHeader("Vary", "Origin");
    respuesta.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE");
    respuesta.setHeader("Access-Control-Allow-Headers", "Content-Type");
  }
  if (peticion.method === "OPTIONS") return respuesta.sendStatus(204);
  next();
});

El comodín como renuncia a la configuración

Poner * permite que cualquier página de internet llame a tu API desde el navegador de sus visitantes. Para una API pública de solo lectura puede resultar aceptable; para una que modifica datos, no.

Conviene recordar qué es CORS y qué no: una protección del navegador. No impide que alguien llame a tu API con un cliente HTTP. La autorización de verdad es otra cosa, y viene a continuación.

Tu cliente, servido desde el mismo origen, no necesita nada de esto. Lo configuras para quien venga de fuera.

La lista mínima de protecciones

Riesgo Protección
Datos inválidos o inesperados Validación con lista blanca en el servidor
Inyección de HTML Escapar todo dato al generar la página
Salirse de la carpeta pública Comprobar la ruta resuelta
Cuerpos enormes Límite de tamaño en el analizador
Abuso de llamadas Limitación por dirección y ventana de tiempo
Cabeceras que delatan Quitar la que anuncia el framework
Escritura sin permiso Autorización en las rutas que modifican
Secretos filtrados Todo en el entorno, nada en el repositorio
app.disable("x-powered-by");
app.use(express.json({ limit: "100kb" }));

const llamadas = new Map();

export function limitar(peticion, respuesta, next) {
  const clave = peticion.ip;
  const ahora = Date.now();
  const registro = llamadas.get(clave) ?? { desde: ahora, total: 0 };

  if (ahora - registro.desde > 60_000) {
    registro.desde = ahora;
    registro.total = 0;
  }

  registro.total += 1;
  llamadas.set(clave, registro);

  if (registro.total > 100) {
    return respuesta.status(429).json({ error: "Demasiadas peticiones", codigo: "LIMITE" });
  }
  next();
}

Es un limitador elemental, porque vive en memoria y se pierde al reiniciar, pero muestra la idea y el 429 es el código que corresponde.

export function requiereClave(peticion, respuesta, next) {
  const enviada = peticion.headers["x-api-key"];
  if (enviada !== configuracion.claveApi) {
    throw new ErrorNoAutenticado("Clave de API no válida");
  }
  next();
}

router.post("/", requiereClave, crearProducto);
router.delete("/:id", requiereClave, borrarProducto);

Leer es público; escribir, no

Una clave en una cabecera es la forma más simple de proteger las operaciones que modifican, y resulta suficiente para un proyecto de aula. Queda lejos de un sistema de usuarios: no distingue quién llama, no caduca y quien la tenga puede todo.

Lo que sí enseña es dónde se pone la comprobación —en el servidor, antes del manejador— y la diferencia entre 401 y 403: la primera significa «no sé quién eres», la segunda «sé quién eres y no puedes».

Una regla no admite excepción: si algún día almacenas contraseñas, se guardan cifradas con una función pensada para eso, nunca en claro ni con un resumen sin sal. En este módulo no se guarda ninguna.

Probar sin abrir el navegador

// pruebas/productos.test.js
import { test, describe, before, after } from "node:test";
import assert from "node:assert/strict";

import { crearApp } from "../src/app.js";

describe("API de productos", () => {
  let servidor;
  let url;

  before(() => {
    servidor = crearApp().listen(0);                // puerto libre
    url = `http://localhost:${servidor.address().port}`;
  });

  after(() => servidor.close());                    // si no, el proceso no termina

  test("lista los productos", async () => {
    const respuesta = await fetch(`${url}/api/productos`);
    assert.equal(respuesta.status, 200);
    assert.ok(Array.isArray(await respuesta.json()));
  });

  test("devuelve 404 si no existe", async () => {
    const respuesta = await fetch(`${url}/api/productos/99999`);
    assert.equal(respuesta.status, 404);
  });

  test("rechaza un producto sin nombre", async () => {
    const respuesta = await fetch(`${url}/api/productos`, {
      method: "POST",
      headers: { "Content-Type": "application/json", "x-api-key": process.env.CLAVE_API },
      body: JSON.stringify({ precio: 10 })
    });
    assert.equal(respuesta.status, 400);
    const cuerpo = await respuesta.json();
    assert.ok(cuerpo.detalles.some((d) => d.campo === "nombre"));
  });
});
{ "scripts": { "test": "node --env-file=.env.pruebas --test pruebas/" } }

Sin instalar nada: el ejecutor y las aserciones se incluyen con Node. Tres detalles determinan su funcionamiento:

  • El listen(0) pide un puerto libre cualquiera, así que las pruebas no chocan con tu servidor de desarrollo. Ahí se cobra haber separado la aplicación del arranque en la sesión 1.
  • El after cierra el servidor. Sin él, el proceso se queda vivo cuando las pruebas ya han terminado y npm test no vuelve nunca.
  • El fichero de entorno propio da a las pruebas su clave y su ruta de datos. La configuración de esta misma sesión corta el arranque si falta una variable obligatoria: sin ese fichero, importar la aplicación desde una prueba mata el proceso antes del primer assert.
Lo que no puede faltar en la batería
  1. El camino feliz de cada operación
  2. Cada código de error del contrato
  3. Los límites: vacío, cero, el valor de la frontera
  4. La regla de negocio que tiene el proyecto
  5. El fallo que ya te costó una tarde

Una prueba que no falla nunca no prueba nada

Después de escribirla, rompe a propósito lo que comprueba y mira si se pone en rojo. Si sigue en verde, la prueba está mal escrita y da una seguridad falsa, peor que no tener prueba.

Usa además datos propios de las pruebas, no los tuyos de desarrollo: una batería que depende de que exista el producto 7 falla el día que alguien lo borra.

Se trabaja

150 minutos · configurar, blindar y probar

Los tres primeros pasos preparan el servicio. Los dos últimos comprueban que las protecciones y las pruebas hacen lo que prometen.

Paso 1 · Configurar · 35 min

  1. Crea el módulo de configuración con sus valores por defecto y sus comprobaciones.
  2. Haz que la aplicación se niegue a arrancar si falta una variable obligatoria, con un mensaje que diga cuál.
  3. Sustituye todos los valores escritos en el código: puertos, rutas de datos, claves y orígenes.
  4. Configura CORS con una lista de orígenes permitidos, tomada de la configuración.
  5. Comprueba desde una página servida en otro origen que la política se aplica.
  6. Actualiza .env.example y documenta en el README qué hace cada variable y cuál es obligatoria.

Antes de continuar: borra una variable obligatoria del entorno y arranca. Debe morir de inmediato con un mensaje claro, no arrancar y fallar después.

Paso 2 · Blindar · 40 min

  1. Aplica el límite de tamaño del cuerpo y quita la cabecera que anuncia el framework.
  2. Escribe el limitador de peticiones y devuelve 429 cuando se supera.
  3. Protege con clave las rutas que modifican, y deja públicas las de lectura.
  4. Comprueba que una llamada sin clave devuelve 401 con el formato de error del contrato, y no una respuesta suelta.
  5. Repasa que toda entrada sigue validándose con lista blanca, incluida la de las rutas nuevas.
  6. Ejecuta npm audit y resuelve o justifica lo que aparezca.

Paso 3 · La batería de pruebas · 45 min

  1. Añade el script de pruebas al package.json y crea el fichero de entorno propio.
  2. Usa un fichero de datos exclusivo de las pruebas, sembrado antes de cada ejecución.
  3. Escribe al menos ocho pruebas: los caminos felices de las cinco operaciones, cada código de error del contrato y dos límites.
  4. Comprueba en cada una las tres cosas: el código de estado, la forma del cuerpo y los campos concretos.
  5. Añade una prueba de la regla de negocio de tu proyecto.
  6. Añade una prueba que compruebe que ninguna respuesta incluye campos internos.
  7. Deja npm test en verde.

Paso 4 · Ataca tu propio servicio · 20 min

Con el servidor arrancado, intenta romperlo desde un cliente HTTP. Rellena la tabla con lo que responde y lo que queda registrado.

Ataque Cómo lo envías Qué responde ¿Correcto?
Cuerpo de varios megabytes
Creación sin la clave de API
Creación con una clave incorrecta
Doscientas peticiones seguidas
Petición de un fichero fuera de la carpeta pública
JSON mal formado
  1. Ninguna fila debe devolver 500, y ninguna debe dejar el servidor caído.
  2. Comprueba con curl -I o desde la pestaña de red que la cabecera del framework ya no aparece.
  3. Demuestra que CORS no protege nada frente a un cliente HTTP: repite desde el fichero .http una petición que un navegador de otro origen tendría bloqueada.
  4. Escribe en dos líneas qué protege entonces esa petición, y cuál de las protecciones de la lista es la que realmente la detiene.

Paso 5 · Pruebas que se ponen en rojo · 10 min

Una batería en verde sobre un código roto es una batería inútil. Compruébalo.

  1. Rompe a propósito, de una en una, estas cuatro cosas y anota qué prueba falla.
Lo que rompes ¿Falla alguna prueba? ¿Cuál?
Cambia el 201 de la creación por un 200
Quita la validación del nombre obligatorio
Haz que la lista devuelva también un campo interno
Quita la comprobación de la clave en el borrado
  1. Cada fila sin prueba que falle señala un hueco. Escribe la prueba que falta.
  2. Devuelve el código a su estado correcto y comprueba que todo vuelve al verde.

Ampliación si has completado el trabajo

Primero termina y comprueba los cinco pasos. El primer reto obliga a mirar el servicio como lo miraría quien quiere romperlo; el segundo convierte la batería en una red de seguridad de verdad.

Reto 1 · Lo que tu servicio sigue sin proteger

Las protecciones de hoy son el mínimo. Escribe el informe de lo que falta, dirigido a alguien que fuera a publicar tu API mañana.

  1. Enumera al menos seis riesgos que tu servicio conserva. La clave única compartida, el limitador que se pierde al reiniciar y el fichero de datos sin copia son tres; busca los otros.
  2. Para cada uno, escribe qué podría ocurrir en la práctica y con qué consecuencia. Un riesgo sin consecuencia descrita no se puede priorizar.
  3. Ordénalos por dos criterios: probabilidad y daño. Anota qué atacarías primero y por qué.
  4. Propón la mitigación de cada uno, con su coste aproximado en trabajo. Algunas se resuelven en una tarde; otras exigen un sistema de usuarios entero.
  5. Busca el listado de los diez riesgos más frecuentes en aplicaciones web que publica OWASP, y comprueba cuáles de los tuyos aparecen. Añade los que se te hubieran escapado.
  6. Decide cuáles no vas a mitigar, y déjalo escrito con su razón. Aceptar un riesgo de forma consciente es una decisión legítima; ignorarlo, no.
  7. Cierra con un párrafo: si esta API se publicara hoy con datos reales, qué es lo primero que romperías tú.

Reto 2 · La batería como red de seguridad

Unas pruebas valen lo que valen los cambios que te atreves a hacer con ellas puestas.

  1. Ejecuta las pruebas con la cobertura que trae Node (--experimental-test-coverage) y anota qué partes de tu código no toca ninguna.
  2. Elige la que más te preocupe y escribe su prueba. No persigas el cien por cien: persigue las líneas donde vive una decisión.
  3. Ahora el cambio: reescribe por dentro una parte de tu servicio —cómo filtra, o cómo normaliza— sin cambiar su comportamiento. Las pruebas deben seguir en verde sin tocarlas.
  4. Si has tenido que modificar alguna prueba para que pase, estaba atada a la implementación y no al comportamiento. Reescríbela para que compruebe lo que se promete, no cómo se cumple.
  5. Añade una prueba del servicio sin HTTP, usando el repositorio en memoria de la sesión 3. Compara cuánto tarda frente a la equivalente que pasa por el servidor.
  6. Decide qué se prueba mejor en cada nivel y escríbelo en tres líneas: hay comprobaciones que solo tienen sentido pasando por HTTP y otras que no lo necesitan.
  7. Mide el tiempo total de la batería. Una batería lenta deja de ejecutarse, y una que no se ejecuta no protege nada.
Objetivo mínimoConfiguración centralizada y comprobada al arrancar, las protecciones de la lista aplicadas, y ocho pruebas en verde que cubren éxitos y errores.
Si lo tienesLa tabla de ataques contestada sin ningún 500, y las cuatro roturas del paso 5 detectadas por alguna prueba.
RetoEl informe de riesgos residuales con su priorización, o la batería sosteniendo una reescritura interna sin tocarse.

Cierre

5 minutos · comprobación y recuerdo

Lista de verificación de la sesión

  • Ningún valor de configuración vive en el código.
  • La aplicación no arranca si falta algo imprescindible.
  • CORS está configurado con una lista, no con un comodín.
  • Las operaciones que modifican exigen autorización.
  • Hay límites de tamaño y de frecuencia, y ningún secreto en el repositorio.
  • npm test pasa y se pone en rojo cuando rompes una regla.

Antes de cerrar · 3 minutos, sin mirar

  1. ¿Qué debe hacer la aplicación si falta una variable de entorno obligatoria, y por qué?
  2. ¿Qué protege CORS y qué no protege?
  3. ¿Qué código devuelve un servicio a quien llama demasiadas veces?
  4. Diferencia entre 401 y 403.
  5. ¿Qué hace listen(0) en una prueba, y por qué hace falta el after?
  6. ¿Cómo compruebas que una prueba sirve de algo?
Ver respuestas

1 · No arrancar, con un mensaje que diga cuál falta: si arranca, el fallo aparecerá lejos de su causa.

2 · Protege al navegador de que una página ajena llame a tu API con las credenciales de quien la visita; no protege frente a un cliente HTTP.

3 · Un 429.

4 · 401 es no autenticado; 403, autenticado pero sin permiso.

5 · Pide un puerto libre cualquiera; sin el after, el servidor queda abierto y el proceso no termina.

6 · Rompiendo a propósito lo que comprueba y viendo si se pone en rojo.

Microprueba semanal 5 · 5–10 minutos

Individual, sin IA y sin apuntes.

  1. ¿Qué debe hacer la aplicación si falta una variable de entorno obligatoria, y por qué?
  2. ¿Qué protege CORS y qué no protege?
  3. Escribe una prueba que compruebe que una entrada inválida devuelve 400.