Saltar al contenido

Patrones reales: webhooks, subidas, proxys y rate-limit

Cuatro usos de producción que consolidan todo el nivel, cada uno bajo demanda. Recibir un webhook verificando su firma HMAC sobre el cuerpo crudo y acusar recibo rápido; aceptar la subida de un fichero con formData validando tamaño y tipo; hacer de proxy hacia una API de terceros para ocultar la clave secreta en el servidor; y frenar el abuso con un rate-limit básico por IP que responde 429 con Retry-After. Con las advertencias de seguridad que separan un endpoint de juguete de uno que aguanta el mundo real.

Webhooks: confiar sin ser engañado

Un webhook es un POST que un servicio externo —una pasarela de pago, un repositorio— lanza a tu endpoint cuando ocurre algo. El problema es la confianza: cualquiera puede enviarte un POST fingiendo ser ese servicio. La defensa es una firma HMAC: el emisor firma el cuerpo con un secreto compartido y adjunta la firma en una cabecera; tú recomputas la firma y compruebas que coincide. Clave: hay que firmar el cuerpo crudo, byte a byte, así que lo lees con request.text antes de parsear nada.

TypeScript
// src/pages/api/webhook.tsimport type { APIRoute } from 'astro';export const prerender = false;const secret = import.meta.env.WEBHOOK_SECRET;export const POST: APIRoute = async ({ request }) => {  const firma = request.headers.get('x-signature') ?? '';  const crudo = await request.text();            // cuerpo sin parsear  if (!(await firmaValida(crudo, firma, secret))) {    return new Response('firma invalida', { status: 401 });  }  const evento = JSON.parse(crudo);  await encolarProceso(evento);                  // trabajo pesado, fuera del ciclo  return new Response(null, { status: 200 });    // acuse de recibo inmediato};// la firma se recomputa con la Web Crypto API, estandar en todo runtimeasync function firmaValida(cuerpo: string, firma: string, secret: string) {  const key = await crypto.subtle.importKey(    'raw',    new TextEncoder().encode(secret),    { name: 'HMAC', hash: 'SHA-256' },    false,    ['sign'],  );  const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(cuerpo));  const esperada = [...new Uint8Array(mac)]    .map((b) => b.toString(16).padStart(2, '0'))    .join('');  return esperada === firma;}

Dos disciplinas más hacen a un receptor de webhooks robusto. Primera: acusa recibo rápido —un 200 en cuanto validas— y delega el trabajo pesado a una cola o a una tarea de fondo; si tardas, el emisor reintentará y procesarás el evento dos veces. Segunda: la idempotencia, tratar cada evento por su identificador único para que un reintento no duplique efectos. Un webhook mal hecho cobra dos veces la misma tarjeta.

Subidas: aceptar un fichero sin desbordarte

Un formulario multipart con un fichero se lee con request.formData, que devuelve un FormData donde cada campo de tipo archivo es un objeto File. Desde ahí tienes su nombre, su tamaño y sus bytes vía arrayBuffer. La regla de oro: valida antes de guardar, porque el cliente controla lo que manda.

TypeScript
export const prerender = false;export const POST: APIRoute = async ({ request }) => {  const form = await request.formData();  const fichero = form.get('archivo');  if (!(fichero instanceof File)) {    return Response.json({ error: 'falta el archivo' }, { status: 400 });  }  if (fichero.size > 2_000_000) {    return Response.json({ error: 'demasiado grande' }, { status: 413 });  }  if (!fichero.type.startsWith('image/')) {    return Response.json({ error: 'tipo no permitido' }, { status: 415 });  }  const bytes = await fichero.arrayBuffer();  const url = await guardarEnAlmacen(fichero.name, bytes);  return Response.json({ url }, { status: 201 });};

El tamaño se rechaza con 413, el tipo no admitido con 415. Nunca confíes solo en la extensión del nombre —se falsifica en un segundo—; comprueba el type y, para datos sensibles, inspecciona los primeros bytes. Y no guardes el fichero en tu servidor de aplicación: súbelo a un almacén de objetos —R2, S3— para no llenar el disco de la función.

Proxy: ocultar la clave en el servidor

Muchas APIs de terceros exigen una clave secreta. Si tu JavaScript de cliente la usara directamente, esa clave viajaría al navegador y quedaría expuesta a la vista de cualquiera. El patrón proxy lo resuelve: tu endpoint recibe la petición del cliente, llama a la API de terceros desde el servidor con la clave, y devuelve el resultado. La clave nunca sale de tu backend.

TypeScript
export const prerender = false;const KEY = import.meta.env.CLIMA_API_KEY;export const GET: APIRoute = async ({ url }) => {  const ciudad = url.searchParams.get('ciudad') ?? 'madrid';  const arriba = await fetch(`https://api.clima.example/v1?q=${ciudad}&key=${KEY}`);  if (!arriba.ok) {    return Response.json({ error: 'servicio no disponible' }, { status: 502 });  }  const datos = await arriba.json();  return Response.json(datos, {    headers: { 'Cache-Control': 'public, max-age=600' },  });};

El proxy es además el sitio natural para cachear —un Cache-Control ahorra llamadas de pago a la API de arriba—, para filtrar qué campos expones y para transformar la respuesta. Un fallo del servicio de terceros se traduce a un 502 Bad Gateway, que dice la verdad: no fallaste tú, falló aquel de quien dependes.

Rate-limit: frenar el abuso

Un endpoint público sin freno es una invitación al abuso. Un rate-limit básico cuenta las peticiones por origen —clientAddress te da la IP, disponible solo bajo demanda— dentro de una ventana de tiempo, y responde 429 Too Many Requests con Retry-After cuando se pasa.

TypeScript
export const prerender = false;const ventana = 60_000;      // 1 minutoconst limite = 30;           // peticiones por ventanaconst cubos = new Map<string, { n: number; hasta: number }>();export const POST: APIRoute = async ({ clientAddress }) => {  const ahora = Date.now();  const cubo = cubos.get(clientAddress);  if (!cubo || ahora > cubo.hasta) {    cubos.set(clientAddress, { n: 1, hasta: ahora + ventana });  } else if (cubo.n >= limite) {    const espera = Math.ceil((cubo.hasta - ahora) / 1000);    return new Response('demasiadas peticiones', {      status: 429,      headers: { 'Retry-After': String(espera) },    });  } else {    cubo.n += 1;  }  return Response.json({ ok: true });};