La semana pasada escribí sobre Glassys, la cara de chat autoalojada que hice para el agente de código que ya está instalado en una máquina. Funcionaba. Lo usaba desde el móvil, el instalador era un solo comando y el repo tenía capturas.
Después hice lo que debería haber hecho antes de publicarlo: releerlo entero como si fuera la instancia de otro, en el servidor de otro, accesible desde fuera de su casa. Eso es otro programa distinto, y el 2 de octubre me costó ocho pasadas conseguir que se comportara como tal.
Un adaptador que falla no debería llevarse la pasarela por delante
Lo peor que encontré no era un agujero de seguridad. Era una salida del proceso.
Un adaptador montaba una cadena de promesas que acababa en .finally() y nunca manejaba el rechazo de la promesa derivada. En Node 22 un rechazo sin manejar termina el proceso: una sola respuesta mala del SDK de un proveedor mataba la pasarela, y con ella todos los hilos, para quien estuviera usando esa instancia. Al lado: tras cancelar una ejecución, el bucle arrancaba un next() nuevo en cada tick y tenía el event loop girando veinte segundos. Y una tarea programada de un solo disparo que fallaba al encolarse volvía a dispararse en el acto, para siempre, que es la clase de bucle que solo aparece en un log que nadie está leyendo.
A los procesos hijo les di el mismo repaso — errores de arranque manejados, esperas resueltas al salir, un SIGKILL tras un margen de gracia, un tope al tamaño de trama — y, como red de seguridad, la pasarela ahora registra los rechazos sin manejar y cierra limpiamente ante una excepción no capturada, para que el gestor de servicios la reinicie en vez de dejar un socket muerto.
Las carreras que se comían tu mensaje
La segunda pasada es la que menos me habría gustado que pillara un desconocido. Un mensaje podía escribirse en un hilo mientras cambiaba la identidad por debajo y luego no ejecutarse nunca: se quedaba en la transcripción con pinta de enviado. Cancelar mientras una sesión aún arrancaba dejaba el arranque en marcha y podía abrir un segundo agente. El apagado seguía aceptando mensajes nuevos de camino a la puerta.
Lo arreglé haciéndome cargo de las fronteras, no añadiendo comprobaciones. El arranque de sesión es de vuelo único, así que un arranque abandonado no puede competir con el siguiente. Los añadidos a la transcripción toman el cerrojo del hilo, así que una línea no puede caer en un hilo recién archivado. Una cancelación aborta el arranque con el que compite en vez de confiar en llegar la segunda, y descarta los eventos de una ejecución que vuelve tarde. Y una ejecución fallida cuenta qué falló de verdad en lugar de «Run failed», una frase que no le ha servido nunca a nadie.
Login, logout y lo que solo hace falta cuando no eres el único
El login no tenía ningún freno. Los intentos desde una misma dirección van ahora de uno en uno, se cuentan antes de ejecutar el hash de la contraseña y no después, y se retrasan hasta treinta segundos; a partir de cuatro esperando, reciben un 429. Sin bloqueo duro, y es a propósito: detrás de un proxy inverso todo el mundo comparte una dirección, así que un bloqueo es sobre todo una forma de dejar fuera al dueño. Las contraseñas muy largas ni siquiera llegan al hash.
Cerrar sesión ahora significa algo. El token lleva un identificador, al salir se revoca esa sesión y se cierran sus sockets, y cambiar la contraseña cierra todos los sockets firmados antes del cambio. El navegador dejó de guardar el token en sessionStorage: ahora es una cookie HttpOnly, y solo se marca Secure cuando la petición llegó de verdad por TLS, porque mucha gente va a correr esto por http plano en su red local y prefiero que funcione a fingir que no pasa.
La lista corta y aburrida
El resto del trabajo de seguridad se lee como una lista de comprobación, que es lo que era. Un endpoint que recibía una URL y la descargaba solo acepta ya hosts https públicos, en vez de poder apuntarse a la propia red de la máquina. Los ficheros de datos se crean 0600 dentro de un directorio 0700. El HTML dejó de permitir scripts en línea, así que el arranque del tema se fue a su propio fichero. Los sockets lentos o que inundan se cortan. Los errores de sondeo dejaron de filtrar lo que sabían. El instalador se niega a correr como root.
Luego tres pasadas que no tienen nada que ver con la seguridad y todo que ver con si da gusto usarlo. Comportamiento: Escape en el panel del hilo retrocede un paso en vez de salir de la aplicación, el socket admite que está reconectando en cuanto se cae en lugar de aparentar normalidad, y una vista del equipo descarta respuestas de una carga de la que ya te habías ido. Velocidad: un bloque en streaming se reparsea como mucho cada 80 ms en vez de en cada trozo, la salida de las herramientas guarda una cola de 64 KiB en lugar de todo, los ajustes y las vistas del equipo se cargan bajo demanda, y el build publica los assets precomprimidos. Teclado y lectores de pantalla: el foco entra en un panel y se queda dentro, Escape cierra el panel antes de poder cancelar tu ejecución — que era una interacción genuinamente desagradable — y el foco vuelve a lo que lo abrió.
La última es la más pequeña y la que más me gusta: una instancia publicada pide ahora a los buscadores que se mantengan lejos, en la página, en robots.txt y en una cabecera. Si pones en un dominio una interfaz de chat contra tu propio servidor, no la quieres indexada, y «nadie va a encontrarla» no es un plan.
Qué haría distinto
Nada de esto necesitó una reescritura. Cada uno era un valor por defecto que había heredado o elegido cuando el único usuario era yo, sentado al lado de la máquina. Así que la lección no es «endurece tu software», que no le ha hecho actuar a nadie. Es más estrecha: en el momento en que publiqué un instalador cambié para quién era el programa, y las suposiciones sobre las que venía construyendo dejaron de ser mías. Publiqué el instalador primero y me hice esa pregunta después.
Está todo en el repositorio si lo usas. Si lo instalaste antes del 2 de octubre, es buena semana para actualizar.
Comentarios