El caso es de lo más normal. Tienes una herramienta de notas y archivos compartidos, alguien quiere enseñarle un proyecto a un cliente que no tiene cuenta, y no le vas a pedir al cliente que se registre para mirar cuatro documentos. Así que haces lo que haría cualquiera: un código de seis dígitos, caduca en cinco minutos, el invitado lo mete y entra.
Eso funcionó desde el primer día. Tres meses después lo había rehecho dos veces, y cuando por fin estaba contento con él, una auditoría me enseñó que el arreglo tenía su propio problema. Lo cuento en orden porque cada versión arreglaba algo real y rompía otra cosa, y creo que ese es el patrón interesante.
La tabla de la primera versión
CREATE TABLE guest_access_codes (
id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
user_id text NOT NULL,
code char(6) NOT NULL,
permission permission NOT NULL,
session_duration_minutes integer NOT NULL,
code_expires_at timestamptz NOT NULL,
is_used boolean DEFAULT false NOT NULL,
used_at timestamptz,
session_expires_at timestamptz
);
Algo hice bien desde el principio, y lo digo porque lo veo mal a menudo: hay dos caducidades. code_expires_at es lo que tarda el código en morir si nadie lo usa, cinco minutos. session_duration_minutes es cuánto dura la visita una vez dentro. Si las juntas en un solo campo, o el código vive ocho horas rodando por WhatsApp o la sesión se corta a los cinco minutos mientras el cliente está leyendo. Ninguna de las dos opciones vale.
Con esto, más los permisos (read o read_write) y una tabla puente para decidir a qué proyectos da acceso cada código, parecía que estaba todo. Lo que tardé en ver es que esa fila representa la invitación. Al invitado no lo representa nada.
Segunda versión: mandar el id de la fila
Lo primero que me molestó fue que el código de seis dígitos viajaba en cada petición. Acababa en logs, a veces en la URL. Así que el cliente pasó a guardar un codeId y las peticiones iban con Authorization: Bearer <codeId>.
El código dejó de viajar, bien. Pero el codeId era el id de la tabla, un entero autoincremental. Si tienes el 41, pruebas el 42, y si esa sesión sigue viva estás dentro. Había cambiado un secreto corto por un identificador que no es secreto de ninguna manera. Me da un poco de vergüenza contarlo porque es de primero de seguridad, pero lo hice, y lo hice con prisa, que suele ser como pasan estas cosas.
Y seguía sin arreglar lo que de verdad me había hecho tocarlo: si dos personas entraban con la misma invitación, compartían codeId. No podías echar a una sin echar a la otra, ni saber cuál de las dos había borrado un fichero.
Tercera versión: el código se canjea y se tira
Aquí separé por fin las dos cosas. El código de seis dígitos pasa a ser un canje: se usa una vez, en el endpoint de conectar, y no vuelve a aparecer. Lo que devuelve ese endpoint es un token de sesión:
const suffix = randomBytes(4).toString("hex");
const sessionToken = `${code}-${suffix}`;
await db
.update(guestAccessCodes)
.set({ isUsed: true, usedAt: now, sessionExpiresAt, sessionToken })
.where(eq(guestAccessCodes.id, record.id));
La respuesta deja de llevar codeId y lleva sessionToken, y el cambio se arrastra por todo el frontend: guestCodeId pasa a guestSessionToken, y el !!guestCodeId que decidía si alguien era invitado se convierte en !!guestSessionToken. Un rename aburrido que me llevó más rato del que me gustaría admitir, porque estaba repartido por sitios que no esperaba.
Lo que gané: poner a null el sessionToken de una fila echa a esa sesión y a ninguna otra. Cada acción se puede atribuir a un canje concreto a una hora concreta. El token ya no es un entero correlativo. Y la caducidad de la sesión se escribe en el mismo update que emite el token, así que no hay dos relojes que se puedan desincronizar.
Estaba bastante contento con esto. Demasiado.
Lo que salió al auditarlo
Meses después pasé una auditoría al código. Salió de todo, y lo peor de la lista no tenía nada que ver con invitados: una ruta de exportación a PDF sin ningún guard de autenticación. Pero lo que más me hizo pensar fue algo que yo mismo había dejado apuntado en un TODO y no había querido mirar.
Fíjate en cómo se construye el token. Son los seis dígitos del código, un guion y cuatro bytes aleatorios. El código lo conoce cualquiera que viera el mensaje de WhatsApp, así que la parte secreta de verdad son esos cuatro bytes, 32 bits. Y ese token, a diferencia del código, no muere a los cinco minutos ni se usa una sola vez: dura lo que dure la sesión y va en cada petición. Todas las protecciones que hacían aceptable un código corto se quedaron en el código. El token no heredó ninguna.
¿Es explotable en la práctica? Contra alguien curioseando, no. Contra alguien que tenga el código y un endpoint sin rate limit durante horas, no me quedaría tranquilo. Lo que debería haber hecho es no mezclar el código en el token para nada y usar un valor aleatorio de verdad, del tamaño que se usa para cualquier token de sesión:
const sessionToken = randomBytes(32).toString("base64url");
Y, ya puestos, guardar en la base de datos un hash del token y no el token, igual que harías con una contraseña, para que un volcado de la tabla no sea una lista de sesiones válidas.
La segunda cosa que salió es de manual y aun así se me pasó. El endpoint de conectar leía la fila, comprobaba que is_used era false y luego hacía el update. Entre la lectura y la escritura hay una ventana, y dos peticiones simultáneas con el mismo código pasaban las dos. No hace falta una transacción grande para arreglarlo, basta con que la condición vaya en el propio update y mirar si ha tocado alguna fila:
const [claimed] = await db
.update(guestAccessCodes)
.set({ isUsed: true, usedAt: now, sessionExpiresAt, sessionToken })
.where(and(eq(guestAccessCodes.id, record.id), eq(guestAccessCodes.isUsed, false)))
.returning({ id: guestAccessCodes.id });
if (!claimed) throw new Error("Código ya usado");
Postgres serializa los dos update sobre la misma fila, y el segundo ya no encuentra is_used = false.
La IP del que falla, que tampoco es la que crees
Para contar intentos fallidos y banear necesitas la IP del cliente, y en esto me equivoqué igual en otro proyecto que tenía baneo permanente por IP. Cogía el primer valor de x-forwarded-for. Esa cabecera es una lista del tipo cliente, proxy1, proxy2 en la que cada proxy añade por la derecha, pero nadie limpia lo que ya venía escrito. El primer valor lo escribe quien hace la petición, o sea, se lo puede inventar. Con eso esquivas tu baneo mandando basura, o peor, consigues que baneen a otro.
Lo cambié a contar desde el final según los proxies de confianza que tengo delante:
const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map(s => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];
Con un proxy delante, el último valor lo pone el proxy y el cliente no lo puede tocar. Si cambias de infraestructura y aparece un proxy más, hay que acordarse de subir la variable, y eso, siendo sincero, no sé si me acordaría.
Si tuviera que hacerlo mañana
Me haría tres preguntas antes de escribir la tabla. ¿Qué objeto estoy revocando cuando echo a alguien? Si la respuesta es "la invitación", va a caer más gente de la que quiero. ¿Qué protege a la credencial que va en cada petición, y no solo a la que se usa una vez? Porque ahí es donde se me cayó la tercera versión. Y ¿qué pasa si esta petición llega dos veces a la vez?
No tengo claro que la versión actual esté bien del todo. Sé que es mejor que las anteriores y sé dónde mirar si falla, que tampoco es poco.
