Disponible para nuevos proyectos Acra, Ghana
Todo escrito

Ciberseguridad · Modelado de amenazas · Backend · Cloudflare Workers

Modelado de amenazas de un formulario de contacto: defensa en profundidad en una API muy pequeña

Un formulario de contacto parece ser la característica más aburrida de un sitio web. También es un punto de conexión público que acepta texto de desconocidos y lo envía a una base de datos y a una bandeja de entrada. Aquí está cómo modelé las amenazas del que está en este sitio, capa por capa, y dónde todavía es débil.

Por Festus Boamah · · 6 min de lectura

El formulario de contacto de este sitio web tiene unos cuarenta renglones de formulario y una ruta de API. Es fácil tratarlo como un detalle. Pero mírelo desde el otro lado: es un punto de conexión público, abierto a toda la internet, que acepta texto de desconocidos y lo escribe en una base de datos y en una bandeja de entrada de correo electrónico. Esa es exactamente la clase de característica que los atacantes buscan, porque nadie la vigila.

Así que antes de publicarlo, me senté y modelé las amenazas. Esta publicación describe ese proceso: qué podría salir mal, qué construí contra cada amenaza, y, igual de importante, dónde el diseño todavía es débil.

Diagrama del flujo de datos del formulario de contacto: el navegador del visitante envía JSON a través de un límite de confianza a un Cloudflare Worker, que aplica seis capas (validación, trampa para abejas, límite de velocidad, Turnstile, SQL parametrizado, correo electrónico seguro de encabezado) antes de almacenar el mensaje en D1 y enviar un correo electrónico de notificación

Comience con el flujo de datos

El modelado de amenazas comienza con dibujar cómo se mueven los datos. El mío es corto:

  1. Un visitante llena el formulario en su navegador.
  2. El navegador envía JSON a POST /api/contact, una pequeña aplicación Hono que se ejecuta en Cloudflare Workers.
  3. El Worker valida la entrada, busca abusos y almacena el mensaje en una base de datos D1 (SQLite).
  4. El Worker envía un correo electrónico de notificación, con la dirección del visitante como Reply-To.

Cada flecha en esa lista es un límite de confianza: navegador a API, API a base de datos, API a correo electrónico. Cada uno es un lugar donde la entrada no confiable cruza a algo que me importa. Para cada límite, hice las preguntas detrás del modelo STRIDE: ¿podría alguien suplantar algo, manipular los datos, negar lo que hicieron, divulgar información, negar el servicio o elevar sus privilegios?

Para un formulario de contacto, tres amenazas destacan: abuso (spam y inundación), inyección (en la base de datos y en el correo electrónico), y privacidad (ahora estoy custodiando nombres, correos electrónicos y palabras de otras personas).

Capa 1: suponer que el navegador está mintiendo

La primera regla es que el formulario en el navegador es una sugerencia, no un control. Cualquiera puede saltarlo y enviar solicitudes con curl. Así que cada verificación se realiza de nuevo en el servidor:

  • El cuerpo debe ser JSON válido, o la solicitud es rechazada con un 400.
  • name debe estar presente y tener como máximo 100 caracteres.
  • email debe coincidir con un patrón básico y tener como máximo 200 caracteres.
  • intent debe ser uno de una lista fija (project, design, teaching, speaking, other). Esta es una lista de permitidos: no trato de adivinar qué aspecto tiene la entrada mala, solo acepto qué aspecto tiene la entrada buena.
  • message debe tener entre 10 y 5.000 caracteres.

Los límites de longitud no son solo cuestión de orden. Establecen un límite en la cantidad de almacenamiento y correo electrónico que una solicitud puede consumir, lo que importa más adelante cuando hablamos de inundaciones.

La misma idea se aplica a CORS. La API solo devuelve encabezados CORS a orígenes en una lista de permitidos (mi dominio y sus despliegues de vista previa). Pero no cuento CORS como un control de seguridad aquí. Detiene a otros sitios web que utilizan el navegador de un visitante para llamar a mi API; no hace nada contra un script que habla con la API directamente. Saber qué control protege contra qué atacante es la mitad del trabajo.

Capa 2: hacer que el spam sea costoso

El spam es el ataque más probable a cualquier formulario de contacto, por lo que tiene tres capas separadas. Ninguna de ellas es suficiente por sí sola; juntas aumentan el costo.

Un cebo. El formulario tiene un campo oculto company que las personas nunca ven y nunca llenan. Los bots simples llenan todos los campos. Si company tiene un valor, la API devuelve { ok: true } y se deshace silenciosamente del mensaje. Devolver éxito es deliberado: un error enseñaría al autor del bot que fue atrapado.

Un límite de velocidad. Cada dirección IP obtiene cinco envíos por hora, contados en Cloudflare KV con una caducidad de una hora. Pasado eso, la API devuelve 429 Too Many Requests. El recuento se toma antes de la verificación del bot, así que un script que sigue fallando aún agota su propia cuota.

Un desafío. El formulario utiliza Cloudflare Turnstile, y el servidor verifica el token con Cloudflare antes de almacenar cualquier cosa. La clave secreta vive en un secreto de Worker, nunca en el repositorio o la página.

Esto es defensa en profundidad a pequeña escala: el cebo atrapa a los bots perezosos de forma gratuita, Turnstile atrapa a la mayoría del resto, y el límite de velocidad limita el daño de cualquier cosa que pase por ambos.

Capa 3: inyección, en dos lugares que la gente olvida

La mayoría de los desarrolladores saben sobre la inyección SQL. La inserción en D1 utiliza una consulta parametrizada:

await c.env.DB.prepare('INSERT INTO messages (name, email, intent, body) VALUES (?, ?, ?, ?)')
  .bind(name, email, intent, message)
  .run();

El texto del visitante se pasa como datos, nunca se pega en la cadena SQL, así que un mensaje que contiene '); DROP TABLE messages; -- se almacena como exactamente eso: un mensaje extraño.

El segundo punto de inyección es menos famoso: encabezados de correo electrónico. El correo electrónico de notificación se construye a mano como un mensaje MIME, y el nombre y el correo electrónico del visitante terminan en los Subject y Reply-To encabezados. Los encabezados de correo electrónico se separan por saltos de línea. Si el “nombre” de un visitante contenía un salto de línea seguido de Bcc: [email protected], una implementación descuidada agregaría un nuevo encabezado y convertiría a mi servidor en un relé de spam. Así que cada valor que se introduce en un encabezado pasa por una función que reemplaza los saltos de línea con espacios primero, y los nombres no ASCII (como Kɔfi o 李明) se codifican correctamente en lugar de pasarse crudos.

El cuerpo del correo electrónico es texto plano, no HTML, así que un mensaje lleno de <script> etiquetas es solo texto cuando lo leo.

Capa 4: los encabezados aburridos que hacen mucho

El sitio web en sí se sirve con encabezados de seguridad estrictos: una Política de Seguridad de Contenido que solo permite scripts de mi propio dominio y los dos servicios de Cloudflare que uso, frame-ancestors 'none' y X-Frame-Options: DENY contra el clickjacking, HSTS para que los navegadores siempre utilicen HTTPS, y un Permissions-Policy que desactiva la cámara, el micrófono y la ubicación. Nada de esto es emocionante, y ese es el punto. La buena seguridad es en su mayoría decisiones aburridas tomadas de manera consistente.

Donde todavía es débil

Un modelo de amenazas que no encuentra debilidades no es un modelo de amenazas. Estas son las que conozco:

  • Los límites de velocidad basados en IP castigan a las redes compartidas. Muchas redes móviles colocan un gran número de usuarios detrás de un pequeño grupo de direcciones IP compartidas (NAT de grado de transportista). En una red como esa, un usuario abusivo podría utilizar el límite horario para personas inocentes que casualmente comparten su dirección. Para un sitio personal, el compromiso es aceptable, pero es un problema de equidad real para los servicios diseñados para usuarios móviles africanos, y es algo en lo que quiero pensar más.
  • El límite de velocidad es aproximado. KV es eventualmente coherente, y leer y luego escribir el contador no es atómico, por lo que un estallido rápido de solicitudes paralelas puede pasar ligeramente el límite. Está bien para amortiguar el spam; no estaría bien para, por ejemplo, limitar los intentos de inicio de sesión, donde usaría un almacenamiento fuertemente coherente.
  • 'unsafe-inline' todavía está en la política de script. El sitio estático utiliza algunos scripts en línea, por lo que la CSP los permite. Esto debilita la protección de la CSP contra el scripting entre sitios. La solución adecuada es pasar a hashes o nonces para cada script en línea.
  • Los mensajes se mantienen indefinidamente, en texto plano. Actualmente, no hay una política de retención. Bajo la Ley de Protección de Datos de Ghana, 2012 (Ley 843), y las buenas prácticas en todas partes, solo debería mantener los datos personales durante el tiempo que los necesite. Un trabajo programado que elimine los mensajes después de un período fijo es el próximo paso obvio.

He clasificado estos cuatro y he escrito cómo planeo solucionar cada uno en un post de seguimiento.

Lo que saqué de ello

Dos cosas me quedaron.

Primero, la seguridad comienza con la pregunta “¿quién está al otro lado de este endpoint?”, no con una lista de herramientas. Turnstile, KV y consultas parametrizadas solo son útiles porque el modelo de amenazas dijo dónde colocarlos.

Segundo, las características pequeñas y poco glamorosas son donde se esconde el riesgo. Nadie prueba la forma de contacto. Son precisamente los sistemas que parecen demasiado pequeños para importar, un portal de resultados de una escuela, una página de donaciones de una iglesia, un formulario de inscripción de una pequeña ONG, que contienen datos reales de personas con la menor protección. Esos son los sistemas que más me importa hacer más seguros, y son donde quiero que vaya mi investigación.

Si detectas algo que me he perdido, me gustaría saberlo de verdad: ponerse en contacto. Sí, a través del formulario.

Festus Boamah

Trabajemos juntos

Construyamos algo que importe.

¿Tienes un producto que construir, una marca que dar forma o una clase que impartir? Me encantaría saberlo.