
Después de publicar ScamDetector, documentar su arquitectura por dentro y repasar los cambios que fueron llegando después, el siguiente paso era inevitable. No basta con que algo funcione, tiene que aguantar. Dedicar una tarde a revisar el código con ojos de atacante es más barato que hacerlo después, cuando ya tienes un problema real entre manos.
Este artículo recoge los cambios que hice para preparar ScamDetector frente a amenazas reales. No son buenas prácticas genéricas sino decisiones concretas motivadas por problemas que encontré al preguntarme "¿qué haría yo si quisiera romper esto?"
Una de las funcionalidades de ScamDetector es extraer URLs de capturas de pantalla. Si recibes un SMS sospechoso y haces una captura, la herramienta detecta las URLs visibles en la imagen para que puedas escanearlas sin copiarlas a mano.
El problema apareció con capturas recortadas. Un SMS que termina con un enlace cortado por el borde de la pantalla, un mensaje reenviado donde la URL se ve parcialmente. Tardé un rato en darme cuenta de qué pasaba, porque los tests con capturas completas funcionaban perfectamente. Pero con capturas del mundo real, Gemini Flash intentaba "completar" la URL que no podía ver del todo. Generaba direcciones web que parecían plausibles pero que simplemente no existían en la imagen original.
La solución fue doble. Por un lado, reforcé las instrucciones del prompt de extracción con reglas explícitas que prohíben inventar, completar o deducir URLs parciales. Solo las que sean completamente visibles y legibles. Por otro lado, añadí una validación más estricta en el servidor que descarta URLs con formato incompleto antes de enviarlas al escáner.
Es un recordatorio de que no puedes confiar ciegamente en la salida de un modelo. Los LLM están diseñados para ser útiles, y a veces ser útil significa fabricar una respuesta convincente cuando la información real no está disponible.
ScamDetector recibe texto escrito por usuarios y ese texto se incluye en el contexto que recibe el modelo de IA. Es el escenario clásico de inyección de prompts, donde alguien intenta manipular el comportamiento del modelo insertando instrucciones dentro de su mensaje.
La defensa obvia es sanitizar la entrada para que el modelo no confunda datos del usuario con instrucciones del sistema. Eso lo implementé. Pero además añadí algo que me parece más interesante: si el modelo detecta un intento de inyección en el mensaje analizado, lo trata como un indicador de fraude y eleva el nivel de riesgo.
Tiene sentido. Una persona que intenta manipular una herramienta de detección de estafas probablemente no lo hace por curiosidad académica. El resultado es que atacar la herramienta la hace funcionar mejor en su propósito original. En lugar de ser una vulnerabilidad, el intento de inyección se convierte en evidencia.
La inyección de prompts no es la única forma de manipular el texto que llega al modelo. Hay caracteres Unicode que son invisibles pero que cambian la identidad de una cadena. Un carácter de ancho cero (ZWSP, ZWJ, ZWNJ) insertado en medio de una URL hace que visualmente parezca idéntica, pero técnicamente es otra dirección completamente distinta. Los caracteres de control bidireccional pueden alterar el orden en que se muestra el texto sin que el usuario lo note. Y los selectores de variación modifican la representación de un carácter sin cambiar cómo se ve en la mayoría de fuentes.
Otro vector que implementé es la detección de homoglifos. La letra cirílica "а" es visualmente idéntica a la latina "a", pero son puntos de código distintos. Un dominio que mezcla caracteres latinos con cirílicos o griegos en la misma palabra es una señal clásica de phishing, y ahora ScamDetector lo detecta antes de que el texto llegue al modelo.
Siguiendo la misma filosofía que con la inyección de prompts, la sanitización no bloquea el análisis sino que genera flags que se pasan al modelo como contexto adicional. Si un mensaje contiene caracteres invisibles sospechosos o mezcla alfabetos de forma inusual, el modelo lo sabe y puede incorporarlo a su evaluación de riesgo. Marcar en lugar de bloquear, porque la decisión final sigue siendo del análisis completo.
Los campos honeypot que describí en el artículo de arquitectura atrapaban a los bots más básicos, pero no a los que se molestan en simular un navegador real. Necesitaba algo más robusto sin sacrificar la experiencia de los usuarios legítimos. Elegí Cloudflare Turnstile en lugar de reCAPTCHA por una razón práctica: el dominio ya estaba en Cloudflare, Turnstile no usa cookies de rastreo y es invisible para el usuario en la gran mayoría de casos.
La implementación protege los endpoints /api/analyze y /api/extract-urls. El flujo original requería un token de Turnstile por cada llamada a la API, pero en Safari esto causaba un problema real: al resetear el widget para obtener un segundo token (para extract-urls después de analyze), Safari forzaba un checkbox interactivo en medio del spinner de carga. La solución fue cambiar a un modelo de sesión efímera. El usuario completa un solo reto de Turnstile al enviar el formulario, el servidor lo verifica contra Cloudflare y emite un token de sesión temporal de un solo uso. Ese token acompaña las llamadas posteriores a analyze y extract-urls, y se invalida tras el análisis. El cambio es invisible para el usuario (sigue sin ver ningún CAPTCHA en condiciones normales) pero eliminó un punto de fricción real en Safari.
Diseñé la verificación para que una caída del servicio de CAPTCHA no impida usar la herramienta. Es una concesión consciente entre disponibilidad y seguridad, con mitigaciones adicionales en las otras capas de defensa. La site key se sirve al frontend desde el servidor para no hardcodearla en el JavaScript, de forma que rotarla solo requiere cambiar una variable de entorno.
Turnstile y la sesión efímera sirven para usuarios reales en un navegador, pero no encajan con clientes máquina. Un agente de IA, un script o un backend que quisieran consultar ScamDetector se chocarían con un reto de JavaScript que no saben resolver. Para esos casos añadí una segunda vía de autenticación mediante Authorization: Bearer <API_KEY> que convive con el flujo principal. Si viene el header, el handler valida la clave con crypto.timingSafeEqual contra una lista configurada en entorno (cada clave tiene un label opcional que aparece en los logs), salta Turnstile y sesión, y aplica su propio rate limit por clave (60 peticiones cada 10 minutos por defecto, configurable). Si la clave es inválida, 401 inmediato sin caer al flujo de sesión. Si no hay header, todo sigue como antes. Son unas pocas líneas que abrieron la puerta a integraciones sin complicar nada para el usuario final.
El artículo de arquitectura explicaba cómo ScamDetector aplica límites de uso por usuario con IPs hasheadas para preservar la privacidad. Esa capa sigue ahí, pero resultaba insuficiente como defensa única.
Un atacante con acceso a múltiples IPs (algo trivial hoy en día con proxies residenciales) podría distribuir las peticiones para que ninguna IP individual superase el umbral. La solución fue añadir capas adicionales de limitación que operan de forma independiente y se complementan entre sí.
Otro detalle que resolví fue la persistencia. En un entorno Docker donde los contenedores se recrean con cada despliegue, los contadores de rate limiting se perdían y cualquiera podía empezar de cero simplemente esperando al siguiente redespliegue. Ahora todos los contadores persisten en disco y sobreviven a reinicios, siempre que el directorio de datos esté montado como volumen.
Cada endpoint tiene su propio bucket de limitación, de forma que el uso legítimo de una funcionalidad no consume la cuota de otra.
Con el tiempo me di cuenta de que el límite plano de 10 peticiones cada 10 minutos trataba igual a todos, y eso no tiene sentido. Un usuario legítimo que analiza mensajes sospechosos no debería compartir la misma tolerancia que alguien que ya ha intentado inyectar prompts. Así que añadí penalizaciones progresivas vinculadas a las señales de los guardrails. Cada detección de inyección reduce progresivamente el límite de peticiones permitidas, y si las detecciones se acumulan el acceso se bloquea temporalmente. El abuso off-topic (por ejemplo, pedirle al modelo que escriba poemas) también se penaliza con bloqueos temporales si se repite.
Las penalizaciones persisten en disco con el mismo mecanismo que los contadores base, así que reiniciar el contenedor no las borra. Y para evitar que alguien desactive las penalizaciones por accidente en producción, el bypass de testing requiere una doble guarda con dos variables de entorno que se excluyen mutuamente en el Dockerfile de producción. Las penalizaciones están siempre activas en producción por diseño.
ScamDetector depende de servicios externos para funcionar. Modelos de IA, búsquedas de reputación, escaneo de URLs. Cualquiera de ellos puede devolver un error transitorio, saturarse o simplemente tardar más de lo esperado.
Centralicé la lógica de reintentos en una utilidad compartida que implementa backoff exponencial. Si una petición falla con un error transitorio, reintenta automáticamente esperando más tiempo entre cada intento. Si el servicio indica cuánto esperar, lo respeta.
Además, todas las llamadas externas tienen un timeout. Si un servicio no responde a tiempo, la petición se cancela y el usuario recibe una respuesta parcial en lugar de quedarse esperando. La búsqueda de reputación de teléfonos, por ejemplo, lanza la consulta al proveedor principal y a uno de respaldo en paralelo, de forma que si uno tarda demasiado el otro puede responder.
La clave es la degradación elegante. Si la consulta de teléfono falla pero el análisis del mensaje funciona, el usuario recibe el análisis con una nota indicando que la reputación del número no se pudo consultar. Una respuesta parcial es mejor que ninguna respuesta.
En el artículo de arquitectura describí cómo ScamDetector soportaba tres backends de IA intercambiables, incluyendo n8n como orquestador visual. n8n es una herramienta potente, pero para lo que realmente hacía aquí (un par de llamadas a la API de OpenRouter) era una dependencia pesada que añadía complejidad de mantenimiento sin aportar un beneficio proporcional. Así que lo eliminé.
Ahora ScamDetector funciona con dos backends. OpenRouter directo es la ruta principal, la más ligera y sin intermediarios. Vercel AI Gateway se mantiene como alternativa serverless con observabilidad integrada. La variable de entorno AI_GATEWAY sigue controlando cuál se usa, y el cambio entre uno y otro no requiere redesplegar.
Lo que hizo que eliminar n8n fuese limpio en lugar de doloroso fue la decisión previa de extraer todos los prompts a un módulo centralizado y compartir la función normalizeResponse() entre backends. Al quitar n8n no hubo que mover lógica ni reescribir prompts, simplemente desapareció un adaptador que ya no hacía falta.
Mientras añadía backends me di cuenta de que tenía funciones idénticas copiadas en cinco módulos diferentes. La utilidad para identificar al usuario, el parseo de JSON, la sanitización de logs. El tipo de deuda técnica que no molesta hasta que te toca corregir un bug en un sitio y descubres que sobrevive en otros cuatro.
Consolidé todo en un módulo compartido. Es refactorización pura, no añade funcionalidad nueva, pero reduce la probabilidad de que un error se corrija en un sitio y pase desapercibido en otro.
De paso, encontré un bug sutil en la limpieza de contadores de rate limiting. La función de limpieza iteraba el mapa de contadores para eliminar los expirados mientras lo modificaba. Funcionaba casi siempre, pero era una condición de carrera esperando el momento oportuno. La corrección fue trivial (tomar una instantánea antes de iterar), pero el bug solo existía porque había cinco implementaciones casi iguales evolucionando por separado. Es el tipo de cosa que como QA sabes que acabará fallando, solo que no sabes cuándo.
Con tantas capas acumuladas (Turnstile, sanitización Unicode, penalizaciones progresivas, detección de inyección, múltiples backends) probar a mano después de cada cambio dejó de ser viable. ScamDetector es JavaScript vanilla sin framework, y apliqué la misma filosofía a los tests: el runner nativo de Node.js (node:test), sin Jest, sin Vitest, sin añadir ni una sola dependencia al proyecto.
Los tests unitarios cubren las funciones puras que sostienen la seguridad: normalización de respuestas, validación SSRF, detección de inyecciones de prompt, sanitización Unicode, rate limiting y gestión de sesiones. Los tests de integración prueban los cuatro handlers HTTP (analyze, verify, extract-urls, urlscan) con mocks de las APIs externas para validar el flujo completo sin consumir créditos. Las funciones internas se exponen mediante module.exports._internal para poder testearlas sin rediseñar la API pública.
Además, la suite incluye 25 tests end-to-end que se ejecutan contra una instancia real y ejercitan el stack completo, incluyendo la verificación de Turnstile y las llamadas al modelo. La suite permite ejecución selectiva por número, rango o patrón de nombre. De paso, mientras escribía los tests para validar que el modelo no genera falsos positivos, mejoré los prompts con una metodología de razonamiento paso a paso y reglas explícitas anti-falso-positivo. Por ejemplo, si un dominio coincide con el servicio oficial que dice representar, eso se trata como señal segura en lugar de sospechosa. Los tests no solo verifican que nada se rompe, también ayudan a afinar la calidad del análisis.
Revisé la interfaz con criterios de accesibilidad WCAG AA y encontré dos cosas. Varios textos secundarios no alcanzaban el ratio mínimo de contraste de 4.5:1, ni en modo claro ni en oscuro. Y los elementos interactivos no mostraban un indicador visual cuando se navegaba con el teclado.
Ajusté los colores y añadí estilos de focus-visible a todos los elementos interactivos. A diferencia de focus a secas, focus-visible solo se activa cuando el usuario navega con teclado, no cuando hace clic con el ratón. Son cambios que la mayoría de usuarios no notarán, pero para quienes dependen del teclado o de tecnologías asistivas marcan la diferencia entre poder usar la herramienta o no.
Tres cambios pequeños que previenen problemas grandes.
La aplicación ya se ejecutaba con un usuario sin privilegios de root en Docker, pero el directorio de datos lo creaba el proceso de build como root. Cuando Docker montaba un volumen sobre ese directorio, la aplicación no tenía permisos de escritura. La solución fue crear el directorio con la propiedad correcta antes de cambiar al usuario sin privilegios.
Consolidé todos los ficheros persistentes en un solo directorio. En Dokploy esto se traduce en un único bind mount, lo que simplifica la configuración y reduce la probabilidad de olvidar montar algo.
Y eliminé los rangos de versiones flexibles en las dependencias. Donde antes había un ^ que permitía actualizaciones automáticas, ahora hay versiones exactas. Las actualizaciones son una decisión explícita, no algo que ocurre de forma silenciosa.
Ninguno de estos cambios surgió de un incidente. No hubo un ataque de inyección de prompts, ni un abuso masivo, ni una queja de accesibilidad. Todo salió de sentarme a revisar el código con la pregunta "¿qué haría yo si quisiera romper esto?" y de iterar sobre cada respuesta hasta tener una defensa concreta.
Esa pregunta es más productiva que cualquier checklist de seguridad. Te obliga a pensar como un atacante con creatividad en lugar de como un auditor con una lista. El rate limiting por IP es suficiente hasta que alguien tiene diez IPs. La validación de URLs es correcta hasta que el modelo inventa una. Los prompts son seguros hasta que alguien intenta inyectar instrucciones. Un CAPTCHA invisible parece innecesario hasta que ves tráfico automatizado en los logs. Los caracteres Unicode son inofensivos hasta que alguien mezcla cirílico con latino en una URL de phishing. Y una suite de tests es un lujo hasta que un cambio rompe algo que funcionaba ayer.
ScamDetector es más sólido ahora, pero el endurecimiento no es un estado al que llegas sino un proceso que no termina. Cada cambio cierra una puerta y revela otra que no habías visto.
Con todas estas capas activas, lo que me faltaba era enterarme en el momento en que alguien intentaba romper algo. Los logs estaban ahí pero yo no los miraba. Lo resolví poniendo alertas push en el móvil con ntfy self-hosted, que es lo que cuento en el siguiente artículo de la serie.
Pruébalo en scamdetector.josemanuelortega.dev y si encuentras algo que se pueda mejorar, cuéntamelo.

ScamDetector combina inteligencia artificial, búsqueda de reputación de teléfonos y escaneo de URLs para ayudarte a identificar estafas digitales. Sin registro, sin datos almacenados.

Repaso completo de las medidas de seguridad que puedes aplicar a un VPS Linux: desde CrowdSec y el firewall hasta el hardening del kernel, pasando por SSH, Docker y las actualizaciones automáticas.

Nuestros posts viven en una base de datos SQLite. Si alguien accede a ella, puede cambiar cualquier artículo sin dejar rastro. Construimos un verificador externo con hashes SHA-256 y firma Ed25519 que vigila la integridad desde un segundo servidor.