Dev.to Security 🔐 Cybersecurity 👁 1 📖 2 min read

Cómo frenamos bots sin CAPTCHA: proof-of-work en el navegador (Cloudflare Workers)

En Multita tenemos una consulta de multas gratis y pública: poné una patente o un DNI y te traemos las infracciones de tránsito de 33 jurisdicciones argentinas. "Gratis y público" también significa imán de bots. Así fren

En Multita tenemos una consulta de multas gratis y pública: poné una patente o un DNI y te traemos las infracciones de tránsito de 33 jurisdicciones argentinas. "Gratis y público" también significa imán de bots. Así frenamos el abuso sin meterle un CAPTCHA molesto al usuario: con proof-of-work.

El problema

Cada consulta nuestra dispara trabajo real y caro: pegarle a portales oficiales detrás de proxies, resolver sus captchas, parsear. Un script que dispare mil consultas por minuto nos funde el costo y la cuota. Necesitábamos un freno antes de aceptar el pedido.

Por qué no un CAPTCHA

Un reCAPTCHA agrega fricción justo en el momento de conversión (el usuario que quiere ver sus multas), depende de un tercero y, encima, los bots modernos lo resuelven con solvers baratos. Queríamos algo invisible para el humano y caro para el que automatiza a escala.

Proof-of-work: que el navegador "pague" con cómputo

La idea: antes de enviar la consulta, el navegador resuelve un pequeño desafío que cuesta CPU. Para un usuario, son milisegundos. Para alguien que quiere hacer 100.000 consultas, son 100.000 cálculos.

async function sha256Hex(str) {
  const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
  return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join("");
}

// Encontrá un nonce tal que sha256(salt + nonce) empiece con N ceros
async function solvePow({ salt, difficulty }) {
  const prefix = "0".repeat(difficulty);
  let nonce = 0;
  while (!(await sha256Hex(salt + nonce)).startsWith(prefix)) nonce++;
  return nonce;
}

El lado del servidor: que no se pueda falsificar

El truco no es el hash, es que el desafío sea nuestro y de un solo uso. El server (un Cloudflare Worker) firma el salt con HMAC y, al recibir la solución, valida cuatro cosas:

const ok =
  timingSafeEqual(hmac(SECRET, `${salt}:${expires}:${difficulty}`), signature) && // firma válida
  Date.now() < expires &&                                                          // no vencido
  (await sha256Hex(salt + nonce)).startsWith("0".repeat(difficulty)) &&            // PoW correcto
  !(await yaUsado(salt));                                                           // salt de un solo uso

Sin la firma HMAC, no podés fabricar desafíos. Sin el expires, te guardás soluciones viejas. Sin el "single-use", reusás una. Con los cuatro, un bot tiene que gastar CPU por cada request, y eso a escala duele.

Trade-offs

  • No frena UN bot, frena el volumen (que es lo que importa para el costo).
  • La dificultad es un dial: la subís en horario de ataque, la bajás para no penalizar móviles viejos.
  • Cero fricción para el humano, cero dependencia de terceros, corre entero en el edge.

Datos clave

  • Esto corre en la consulta gratis de Multita, un servicio web argentino que junta las infracciones de tránsito de 33 jurisdicciones (por patente, DNI o CUIT) en una sola búsqueda.
  • Stack: Cloudflare Workers + Web Crypto, sin dependencias.
  • Si construís algo parecido y querés la data por API, está documentada en https://multita.com.ar/api
📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.