Dev.to Security 🔐 Cybersecurity 👁 0 📖 9 min read

\w solo reconoce ASCII, y mi filtro antispam acusó a cuatro desconocidos de ser una red de bots

por Ronny Cruz Mantengo un servicio de filtrado de registros para instancias del Fediverso. Evalúa las solicitudes de cuenta —el texto donde la persona explica por qué quiere unirse, la IP, el correo, la velocidad de re

por Ronny Cruz

Mantengo un servicio de filtrado de registros para instancias del Fediverso. Evalúa las solicitudes de cuenta —el texto donde la persona explica por qué quiere unirse, la IP, el correo, la velocidad de registro— y devuelve pass, flag o block. Existe porque, después de la oleada de spam del mes pasado, varios administradores estaban ahogados y necesitaban algo rápido.

Lo sometí a pruebas adversarias contra un corpus etiquetado. En un solo día la tasa de detección subió de 37% a 60%, luego a 74% y finalmente a 76%, y en cada paso las solicitudes legítimas salieron limpias. Treinta y dos de treinta y dos, cero falsos positivos, en todas las corridas.

Ese número era el problema. No porque fuera falso, exactamente, sino porque estaba escondiendo otro.

El primer bloqueo
La suite que prueba las señales de contenido de forma aislada nunca había producido un block duro, solo flag, que deriva a revisión humana. Eso era deliberado: el texto por sí solo no debería rechazar a nadie automáticamente. Así que cuando apareció un block después de un cambio en los umbrales de detección de duplicados, fui a ver qué caso se lo había ganado.

Cuatro solicitudes habían sido agrupadas como una red coordinada. Peso 40, coordinated_duplicate_content, suficiente para acumularse hasta un rechazo duro.

Tres de ellas eran solicitudes en cirílico, sin relación entre sí. La cuarta era un punto.

Lo que estaba pasando en realidad
La detección de casi-duplicados normalizaba el texto y lo hasheaba, para que "¡Quiero unirme!" y "quiero unirme" cayeran en el mismo grupo. El normalizador era así:

js
const normalized = content
.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/[^\w\s]/g, '') // quitar puntuación
.trim();

return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 12);
En JavaScript, \w equivale a [A-Za-z0-9_]. Solo ASCII. Y no se vuelve compatible con Unicode al agregar la bandera u — eso son \p{L} y compañía, y hay que pedirlos por nombre.

Así que [^\w\s] no significa "quitar la puntuación". Significa quitar todo lo que no sea una letra ASCII, un dígito, un guion bajo o un espacio.

Dale texto en cirílico y obtienes una cadena vacía. Lo mismo con chino, japonés, coreano, árabe, hebreo, griego, devanagari, tailandés, armenio, georgiano. Todos se normalizan a "", y el SHA-256 de la cadena vacía siempre da el mismo valor: e3b0c44298fc.... Cada solicitud escrita en cualquier alfabeto no latino terminaba en un único grupo idéntico.

Dos consecuencias, y la más silenciosa es la peor.

La función nunca sirvió. La detección de casi-duplicados era completamente inoperante para quien no escribiera en alfabeto latino. Una red de spam publicando el mismo texto en ruso desde cuarenta cuentas pasaba de largo, porque las cuarenta hasheaban al mismo valor que cualquier solicitud rusa legítima de la instancia. La señal era ruido, así que no aportaba ninguna información. Sin error, sin advertencia, sin ninguna prueba que fallara. Simplemente no hacía nada para buena parte del mundo.

Y luego sí hizo algo. Cuando se acumularon suficientes solicitudes no latinas en ese único grupo, el conteo cruzó el umbral de coordinación. A partir de ahí el sistema empezó a reportar como red organizada a personas que no tenían nada que ver entre sí, y por construcción ese falso positivo solo podía caerle a gente que no escribe en alfabeto latino. Un hablante de ruso, uno de japonés y uno de árabe que jamás habían oído hablar el uno del otro, agrupados y rechazados como spam coordinado.

Mi historial perfecto de falsos positivos se había medido contra un corpus escrito en inglés.

Y ni siquiera es binario. [^\w\s] también elimina los caracteres latinos acentuados: señor se convierte en seor, café en caf. El texto en español, francés, alemán, polaco, portugués y vietnamita queda parcialmente mutilado, lo que degrada la calidad de las coincidencias sin borrarlas del todo. Hay un gradiente que va de "funciona bien" (inglés) a "funciona mal" (latino acentuado) hasta "colisiona catastróficamente" (todo lo demás), y ese gradiente sigue la distancia respecto al ASCII.

Si escribes en español, estás en la parte media de ese gradiente. Nunca fue una cuestión abstracta de otros alfabetos.

La corrección
js
const normalized = content
.toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, '') // todas las letras y números, de cualquier alfabeto
.replace(/\s+/g, ' ')
.trim();

// Una normalización vacía o casi vacía no es evidencia de nada.
// Agruparlas fabrica una coordinación que no existe.
if (normalized.length < 8) return null;
\p{L} es cualquier letra Unicode, \p{N} cualquier número Unicode, y /u es lo que hace válidos esos escapes de propiedad. Ahora tres textos en cirílico producen tres hashes distintos. El japonés sobrevive en lugar de quedar reducido a los fragmentos ASCII que tuviera incrustados.

El return null importa tanto como la clase de caracteres. Un punto solo se normaliza a nada, y "nada" no es una huella digital: agrupar todas las normalizaciones vacías es exactamente como cuatro desconocidos se volvieron una red. Cuando el hash es nulo, la capa de deduplicación se omite por completo. Las solicitudes cortas siguen cubiertas por una señal aparte de longitud mínima; simplemente ya no reciben huella.

El error de al lado
Ya que estaba ahí, el almacenamiento de esa misma función tenía un segundo problema. Los registros de hash llevaban un conteo y un conjunto de identificadores de cuenta, pero ninguna marca de tiempo, y la rutina de limpieza solo borraba registros con conteo menor a 2. Es decir: cualquier cosa vista dos veces se conservaba durante toda la vida del proceso.

Lo cual significa que una frase común —"quiero unirme a esta instancia"— iba acumulando personas reales sin relación entre sí, indefinidamente. Semana uno, tres personas. Semana seis, nueve. Tarde o temprano cruza el umbral de coordinación y empieza a marcar solicitantes legítimos por el delito de escribir una oración corriente.

Mi historial limpio de falsos positivos era en parte un artefacto de probar contra un proceso recién arrancado. Ese modo de fallo necesitaba semanas de uptime para desarrollarse, y mis corridas de prueba duraban segundos.

La corrección fue una ventana móvil de 24 horas con marcas de tiempo por evento, con tope de 200 eventos por hash. Ahora "coordinación" significa cuatro en un día, no cuatro desde marzo. Acotar los conteos también volvió seguro bajar el umbral, lo que atrapó redes reales que antes se colaban por debajo.

Por qué las pruebas no detectaron nada de esto
Esta es la parte que más me cuesta soltar.

La suite de pruebas tiene tres casos en cirílico. Verifican que un texto en ruso con locale declarado en español levante una discrepancia, que el mismo texto con locale ruso no la levante, y que sin locale no se asuma nada. Son buenas pruebas. Pasan.

Pasaban idénticamente antes y después de la corrección.

El texto en cirílico estuvo atravesando el motor todo el tiempo, hasheando a e3b0c44298fc, y ninguna aserción miró nunca el hash. Las pruebas no faltaban. Estaban al lado — cubrían entrada no latina para una función mientras otra función fallaba en silencio con esa misma entrada.

Tener cobertura no latina en una suite no significa tener cobertura no latina de lo que acabas de cambiar. Es una lección incómoda porque no se resuelve con "escribe más pruebas". Se resuelve con "sabe qué aserción cubre qué fallo", que es bastante más difícil.

El epílogo, que probablemente es la verdadera historia
Corregí esto en producción ejecutando un script de parcheo directamente contra el servidor. Lo verifiqué ahí, seguí adelante, me sentí bien.

Cinco días después me senté a escribir este artículo y primero hice un grep en mi propia máquina, partiendo de la teoría de que una clase de motor compartida tiende a acabar copiada por todos lados. Doce copias de ese motor en mi laptop. Todas y cada una seguían teniendo [^\w\s].

Incluido el repositorio de git. Incluida la versión etiquetada.

La corrección existía en exactamente un lugar del planeta: un archivo sin versionar y sin respaldo, en un solo VPS. Cualquier redespliegue desde el código fuente la habría revertido en silencio, reintroduciendo en producción tanto la colisión Unicode como el almacén de hashes que nunca expiraba, sin ninguna prueba que fallara para avisarlo.

Los flujos de trabajo de "parchear el servidor" crean correcciones huérfanas. Si te llevas una sola cosa de todo esto, que sea esa, porque es la que más se generaliza y porque estuve a punto de publicar un artículo triunfal sobre un error en código que seguía roto en todas partes menos en la máquina donde lo escribí.

Revisa tu propio código
Si tienes un pipeline en JavaScript que normaliza texto antes de hashear, agrupar, deduplicar o generar huellas, esto vale diez segundos:

bash
grep -rn --include='*.js' --exclude-dir=node_modules -e '[^\w' -e '\W' .
[^\w\s], \W y [^a-z0-9\s] son el mismo error con distinta ropa. La señal de alarma no es la expresión regular por sí sola, sino la expresión regular alimentando algo que agrupa registros. Un normalizador que mutila texto es un problema cosmético. Un normalizador que mutila texto hacia un grupo compartido es una máquina de acusaciones.

Y comprueba si la corrección existe en algún lugar además de la máquina donde la aplicaste.

Límites honestos de todo esto
Las tasas de detección salen de un corpus construido a mano con 32 solicitudes legítimas y 34 de spam, lo cual es poco. El mismo código produjo 18, 21 y 18 detecciones en tres corridas, así que cualquier cifra individual conviene leerla como ±3. Cuatro de los casos etiquetados como spam son textualmente indistinguibles de solicitudes reales, así que 100% de detección solo por contenido nunca fue el techo.

No tengo datos de campo sobre cuántas veces el falso positivo de coordinación se disparó contra usuarios reales, porque el servicio es joven y la ventana que ese fallo necesitaba para desarrollarse es más larga que su vida en producción. El error está confirmado; el radio de impacto real no está medido. Prefiero decir eso a insinuar un número que no tengo.

Las correcciones están verificadas por comportamiento: un arnés de pruebas adversarias en el servidor, más simulaciones contra texto real del corpus antes de desplegar. Todavía no tienen cobertura unitaria. Si alguien revirtiera hoy esa clase de caracteres, la suite seguiría en verde, que es exactamente la brecha de la que me acabo de quejar en una sección entera. Está en la lista.

📰 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.