Seguridad y cifrado
Amenazas, sesiones, MFA, cifrado, assets firmados, auditoría y CSP.
En esta página
- Modelo de amenazas (resumen)
- Sesiones, JWT y revocación
- MFA TOTP
- Cifrado en reposo
- URLs de assets firmadas
- Adjuntos: magic-bytes y antivirus
- Retención y borrado
- Filtro de spam y blocklist
- Importar y exportar correo
- Acceso de administración: niveles y sesión privilegiada
- Auditoría
- Rate limit
- Cabeceras, CSP y CSRF
- Contenido del correo entrante
- Extensiones: sandbox y SSRF
- Modelo del sandbox y código de terceros
- Extensiones: validación, preferencias y obligatorias
- Límites conocidos
Esta página describe lo que el código hace hoy, no lo que sería deseable. Cada dato (nombre de variable, valor por defecto, límite) se verificó contra el código. Los huecos conocidos están en Límites conocidos y el mapeo a normas en CIS / NIST / ISO.
Modelo de amenazas (resumen)#
| Amenaza | Mitigación principal | Sección |
|---|---|---|
| Robo o fijación de sesión | Cookie HttpOnly __Host-, JWT con jti y tokenVersion, tope absoluto de sesión | Sesiones |
| Fuerza bruta y relleno de credenciales | Rate limit por IP y por cuenta, bloqueo, bcrypt coste 12, MFA TOTP | Rate limit |
| XSS en correo entrante | DOMPurify + iframe con sandbox sin allow-same-origin + CSP interna | Contenido del correo |
| Fuga de secretos en BD/backups | AES-256-GCM (v3) con keyId y rotación; tokens OAuth cifrados | Cifrado |
| Acceso a adjuntos ajenos (IDOR) | Propiedad verificada en servidor y URLs de assets firmadas con HMAC | Assets firmados |
| Malware en adjuntos | Magic-bytes, extensiones peligrosas y antivirus opcional | Adjuntos |
| SSRF desde extensiones | safe-fetch: solo https, IP privadas bloqueadas, validación en la conexión | Extensiones |
| Suplantación entre frontend y backend | Firmas Ed25519 por dominio, sin secretos compartidos | Arquitectura |
| CSRF | Comprobación de Origin en /api/* que cambian estado | Cabeceras y CSP |
Sesiones, JWT y revocación#
- JWT HS256 firmado con
NEXTAUTH_SECRET(propio de cada frontend). En producción, si falta, la operación falla; en desarrollo se usa un valor fijo solo para desarrollo. - Claims:
sub,email,name,use:"session",jti,tv(tokenVersion),at(instante del login) ymfa:truesi hubo segundo factor. - Cookie
__Host-next-auth.session-tokenen producción (HttpOnly, SameSite=Lax, Path=/, sin Domain, Secure). Se sigue leyendo la cookie antigua sin prefijo mientrasSESSION_COOKIE_LEGACY_READesté activo (por defecto sí); ponSESSION_COOKIE_HOST_PREFIX=0para desactivar el prefijo. - Vigencia deslizante:
SESSION_TTL_SECONDS(por defecto 86400 = 24 h de inactividad) y tope absolutoSESSION_ABSOLUTE_MAX_SECONDS(por defecto 1209600 = 14 días desde el login). - Revocación:
User.tokenVersioncierra todas las sesiones (cambio de contraseña, alta/baja de MFA); la tablaRevokedSession(jti)cierra una sola (logout). Cache de 5 s por instancia (SESSION_REVOCATION_CACHE_MS), así que en varias instancias una revocación puede tardar hasta ese tiempo. - Tokens antiguos sin
jti: se aceptan hastaSESSION_TTL_SECONDSdesde su emisión;SESSION_ALLOW_LEGACY_TOKENS=falselos rechaza ya. - Contraseñas: mínimo 12 caracteres, máximo 72 bytes, lista de comunes, sin repeticiones ni el nombre del email; bcrypt coste 12 con comparación de tiempo constante.
Si falta DDL
Sin la columna tokenVersion o la tabla RevokedSession el mecanismo correspondiente se omite con un aviso (falla abierto). Ejecuta npm run db:ensure antes de desplegar (Operación).
MFA TOTP#
- TOTP estándar (RFC 6238: SHA1, 6 dígitos, 30 s, ventana ±1 paso) con anti-replay atómico. El secreto (160 bits) se guarda cifrado en
UserMfa.secretEnc; el QR se genera en el enrolamiento. - Diez códigos de recuperación (
XXXXX-XXXXX), guardados como HMAC-SHA256 conMFA_RECOVERY_PEPPER(por defectoNEXTAUTH_SECRET; fíjalo si vas a rotarNEXTAUTH_SECRET). Un solo uso; se pueden regenerar. - Obligatorio para los correos de
ADMIN_EMAILS(se desactiva conMFA_ENFORCE_ADMIN=false) y para todos siMFA_REQUIRED_ALL=true. Opcional para el resto. - Intentos: 6 cada 5 min por usuario y 40 cada 15 min por IP (429).
- Login: si MFA aplica,
POST /api/auth/logindevuelvemfaRequiredomfaEnrollRequiredcon unmfaTokende 5 min y no emite cookie;POST /api/auth/mfa/verifyemite la sesión conmfa:true. - Si la tabla
UserMfano existe y MFA es obligatorio para ese usuario, el login responde 503 (falla cerrado). requireAdminexige email enADMIN_EMAILSy sesión conmfa:truecuando MFA es obligatorio.
Cifrado en reposo#
| Formato | Estructura | Algoritmo y clave |
|---|---|---|
| v1 (legado) | iv:ct en hex | AES-256-CBC sin autenticación. Solo se lee. |
| v2 | v2:iv:tag:ct | AES-256-GCM, clave por HKDF-SHA256 (info bloomx:data-encryption:v2). |
| v3 (por defecto) | v3:<keyId>:iv:tag:ct | AES-256-GCM, HKDF con keyId en la info y en el AAD. |
- Clave vigente:
DATA_ENCRYPTION_KEYcon idDATA_ENCRYPTION_KEY_ID(por defectok1). Claves anteriores para descifrar:DATA_ENCRYPTION_KEYS_PREVIOUS="k0:secreto,k1:otro".NEXTAUTH_SECRETactúa como reserva (idauth);ENCRYPTION_REQUIRE_DEDICATED_KEY=trueobliga a definir la clave dedicada en producción. ENCRYPTION_WRITE_FORMAT(por defectov3;v2para hacer rollback).- Rotación: define una clave nueva con id nuevo, pasa la anterior a
DATA_ENCRYPTION_KEYS_PREVIOUS, deja que el re-cifrado perezoso migre lo que se lea y retira la antigua cuando ya nada la use. - Qué se cifra: tokens OAuth de
Account(con migración perezosa,ACCOUNT_TOKENS_LAZY_MIGRATE), secreto TOTP, ajustes de expansiones y mensajes sellados del servidor. Los cuerpos de correo y los adjuntos no se cifran a nivel de aplicación;S3_SSE=AES256|aws:kmsactiva el cifrado del lado del proveedor. - Claves internas: la clave servidor-a-servidor de cada instancia se deriva de su
NEXTAUTH_SECRETcon HKDF-SHA256 (infobloomx-internal-v1); la clave de URLs de assets y la del token pendiente de MFA también se derivan. Por eso no hay secretos que compartir con el backend. - El backend usa su propio formato
enc:v1:(AES-256-GCM conDATA_ENCRYPTION_KEYdel backend, sinkeyId) para la clave de Resend del dominio y las credenciales de extensiones. - Credenciales de extensiones (orden: dominio, heredadas, entorno del servidor): las variables de plataforma (base de datos, almacenamiento, cifrado, claves privadas,
EXTENSION_*) están en una lista negra que ninguna extensión puede leer; el secreto solo existe dentro del sandbox y nunca se devuelve ni se registra. El modo legado (sin firma) sigue recibiendo credenciales, con rate limit, auditoría por ejecución y sinservices.mail;requireSignaturelo rechaza.
URLs de assets firmadas#
Los adjuntos se sirven por /api/assets/<clave>?exp=<epoch>&sig=<base64url>, con sig = HMAC-SHA256(clave_de_firma, "asset:v1\n" + clave + "\n" + exp). La clave de firma es ASSET_SIGNING_KEY o, si no existe, se deriva de NEXTAUTH_SECRET.
- TTL:
ASSET_URL_TTL_SECONDS(3600) para adjuntos recibidos yASSET_UPLOAD_URL_TTL_SECONDS(2592000 = 30 días) para subidas. - Enlaces sin firma:
ASSET_UNSIGNED_INBOUND(por defectoowner: exige sesión del propietario) yASSET_UNSIGNED_UPLOADS(por defectoallow); valoresallow | owner | deny. Cuando caduquen los enlaces antiguos, sube las subidas aownerodeny. - El propietario autenticado siempre puede acceder aunque la firma haya caducado. Solo se sirven los prefijos
attachments/y los adjuntos entrantes; se rechazan..,\,\0y claves de más de 1024 caracteres. Los rechazos devuelven 404 y se auditan. - Respuesta con
nosniff,CSP: default-src 'none'; sandbox,Content-Disposition: attachmenty los tipos activos comoapplication/octet-stream.
Adjuntos: magic-bytes y antivirus#
- Siempre activo: detección por cabecera del archivo (ejecutables PE/ELF/Mach-O, scripts
#!, JAR/APK, HTML/SVG/XML activos) y lista de extensiones peligrosas (se revisan todas las del nombre:a.pdf.exese detecta). En subidas se rechaza; en entrantes se bloquea el ejecutable y se sanea lo raro (INBOUND_BLOCK_DANGEROUS_EXT=falsepara no bloquear por extensión). Límite de subida: 200 MB. - Antivirus opcional (ClamAV REST o compatible): define
AV_SCAN_URL(AV_SCAN_TOKEN,AV_SCAN_FIELD=file,AV_SCAN_TIMEOUT_MS=8000). Archivos mayores queAV_SCAN_MAX_BYTES(25 MB) se omiten.AV_FAIL_MODE=open(por defecto) acepta si el AV falla y lo audita;closedbloquea con 503 en subidas.
Retención y borrado#
| Variable | Por defecto | Efecto |
|---|---|---|
RETENTION_SPAM_DAYS | 30 | Borra spam más antiguo (0 = desactivado) |
RETENTION_TRASH_DAYS | 0 (off) | Borra la papelera por antigüedad de creación |
RETENTION_RAW_DAYS | 0 (off) | Borra el raw.json del webhook |
AUDIT_RETENTION_DAYS | 365 | Purga AuditEvent (0 = no purga) |
SECURE_MESSAGE_TTL_DAYS | 30 | TTL máximo de mensajes sellados (+1 día de gracia) |
RETENTION_BATCH | 200 | Lote por pasada (tope 1000) |
El borrado es completo: HTML, texto, raw.json y adjuntos con conteo de referencias (un objeto compartido no se borra hasta que nadie lo use). Se ejecuta con GET/POST /api/admin/retention con Authorization: Bearer <CRON_SECRET> o sesión admin; ?dryRun=1 solo cuenta.
Filtro de spam y blocklist#
El correo entrante pasa por la lista de bloqueo (remitente del sobre, From y dominio DKIM) antes de guardar nada: si acierta no se almacena cuerpo, adjuntos ni crudo, se responde 200 y no se genera rebote (sin *backscatter*). Después, el motor puntúa por señales de autenticación, suplantación de marca, enlaces, adjuntos y contenido. Los permitidos no eluden la suplantación ni el malware; no se pueden bloquear el dominio propio ni los administradores; las regex de las listas pasan por la defensa ReDoS de las reglas. El registro de decisiones no guarda contenido. Ver Filtro de spam y remitentes.
Importar y exportar correo#
Mueve el contenido de los correos de todos los usuarios, así que solo requireAdmin, con re-autenticación reciente (10 min), confirmación escribiendo el dominio, límite de peticiones y de trabajos concurrentes, propiedad estricta de cada trabajo, auditoría sin contenido, enlaces de descarga firmados y de una sola vez, paquete cifrable (AES-256-GCM + scrypt), y defensas contra zip-slip, zip-bomb y enlaces simbólicos. Detalle en Importar y exportar correo.
Acceso de administración: niveles y sesión privilegiada#
- Niveles de permisos (
permission_level0-4: user, support, operator, admin, superadmin). Cada ruta y comando declara su nivel mínimo en un solo sitio (lib/admin-levels.ts) y se comprueba enrequireLevel(n)aunque se llame a la API directamente (403).ADMIN_EMAILSes la semilla/rescate: nivel 4 fijado por entorno. Ver la escala y las reglas. - Mínimo privilegio y separación: solo se asigna un nivel menor al propio, nadie modifica a alguien de nivel igual o superior ni a sí mismo, y los cambios exigen step-up con MFA, se auditan (
admin.permissions.changed) y avisan por correo;ADMIN_EMAILS_LOCKED=truedesactiva la gestión por consola. - Una sola sesión privilegiada por cuenta (consola web y CLI juntas): la nueva revoca la anterior en el acto (401
superseded), con aviso por correo y push; un reemplazo desde otra IP o dispositivo alerta además a los superadmins. Cierre por inactividad (15 min, 5-60 configurable) y tope absoluto de 12 h (401expired). Tres reemplazos en 10 min bloquean el acceso privilegiado hasta que un superadmin lo desbloquee. Ver sesión privilegiada. - Tokens de CLI: hash SHA-256, caducidad (12 h por defecto, 30 d máx.), ámbitos, nivel acotado por la cuenta y revocación inmediata al bajar el nivel; los tokens «machine» de automatización son de solo lectura, ≤ 7 d, nunca nivel 4 y no ocupan el slot.
- Controles de referencia: NIST AC-6 (mínimo privilegio), AC-10 (sesiones concurrentes), AC-11/AC-12 (cierre por inactividad y terminación), IA-2(1) e IA-11 (MFA y reautenticación), AU-2/AU-3; ASVS V3 (sesiones) y V4 (control de acceso); CIS 5 y 6; ISO 27001 A.5.15, A.8.2, A.8.5 y A.8.15.
Auditoría#
- Cada evento sale como línea JSON por stdout y se inserta en
AuditEvent(asíncrono, nunca rompe la petición; 5 fallos seguidos pausan la persistencia 60 s;AUDIT_DB=offla desactiva). - Redacción: se omiten claves con
pass|token|secret|authorization|cookie|key|otp|code|recovery, los emails se enmascaran y las cadenas se recortan a 500 caracteres. - Se registran, entre otros:
auth.login.*,auth.mfa.*,auth.logout,auth.password.changed,auth.register.*,admin.*,assets.denied,attachment.blocked,av.*,email.deleted,retention.*. - El backend audita a stdout (sin tabla). No hay integridad criptográfica del registro ni SIEM integrado.
Rate limit#
Sin Upstash el límite es por instancia (memoria, ventana fija). Con UPSTASH_REDIS_REST_URL y UPSTASH_REDIS_REST_TOKEN es global (claves hasheadas, sin emails ni IP en claro). Si Redis falla (timeout RATE_LIMIT_REDIS_TIMEOUT_MS=800; tres fallos abren el circuito 30 s), RATE_LIMIT_ON_ERROR=memory (defecto) cuenta en memoria y open deja pasar; nunca bloquea a todos. La IP sale de x-forwarded-for: solo es fiable tras un proxy que la sobrescriba.
| Endpoint | Límite |
|---|---|
| Login | 20 / 15 min por IP y 10 / 15 min por cuenta |
| Registro | 5 / h por IP |
| MFA | 6 / 5 min por usuario y 40 / 15 min por IP |
set-cookie / refresh | 60 / min y 120 / min |
| Subidas | 120 / 10 min por usuario |
| Mensaje sellado (crear / abrir / consumir) | 30 / h por email; 60 / min y 120 / h por IP y id; 120 / min y 240 / h |
| Reserva de citas | 10 / h por IP y 3 / h por email |
| Elixir | 1200 / h por usuario |
| Baja (unsubscribe) | 30 / min |
Cabeceras, CSP y CSRF#
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload,X-Content-Type-Options: nosniff,X-Frame-Options: SAMEORIGIN,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policy(cámara, micrófono, geolocalización, pagos y USB desactivados),Cross-Origin-Opener-Policy: same-origin-allow-popups./api/auth/*conCache-Control: no-store;/secure/*y/api/secure-message/*conno-referrer,no-store(ynoindexen/secure/*).- CSRF: en
/api/*que cambian estado, si llegaOriginsu host debe coincidir con elHost(403 si no). SinOrigin(clientes no navegador) o conBearerno se bloquea. Están exentos los webhooks, cron,/api/config,/api/register, reservas públicas de citas y parte de/api/auth.
default-src 'self';
script-src 'self' 'unsafe-inline' https://accounts.google.com https://apis.google.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: blob: https:;
font-src 'self' data: https://fonts.gstatic.com;
connect-src 'self' https: wss:;
media-src 'self' data: blob: https:;
frame-src 'self' blob: data: about: https://accounts.google.com;
worker-src 'self' blob:; manifest-src 'self';
object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self';La CSP permite 'unsafe-inline' en scripts y estilos, e img-src/connect-src con https: genérico. Es una limitación real (ver Límites).
Contenido del correo entrante#
- DOMPurify con perfil HTML (sin SVG/MathML; se prohíben script, iframe, object, embed, form, input, meta, link, base, audio/video y atributos
srcset,ping,formaction,data-*); URIs limitadas a http, https, mailto, tel y cid; los enlaces recibentarget=_blankyrel="noopener noreferrer nofollow". SafeIframe:srcDocre-saneado consandbox="allow-popups allow-popups-to-escape-sandbox allow-scripts"(sinallow-same-origin: origen opaco) y una CSP propia por<meta>(default-src 'none', script solo con nonce del propio visor).- Imágenes remotas bloqueadas por defecto (se permiten por correo o por remitente; la política vive en
localStorage, clavebloomx:remote-images:v1). - Aviso anti-phishing junto al enlace cuando el texto visible no coincide con el destino, hay punycode (
xn--), IP como host,user@en la URL o un acortador conocido. - Cabeceras de autenticación (SPF/DKIM/DMARC) del mensaje recibido se muestran como insignia.
Extensiones: sandbox y SSRF#
- Cada invocación corre en un
worker_threadspropio conenvvacío y un contextonode:vm; toda E/S sale por mensajes al hilo principal. Límites: heap 128 MB, timeout duro 25 s, 20 fetch, 5 llamadas de IA, 500 mensajes y 200 logs por invocación, 8 workers simultáneos (EXT_SANDBOX_MAX_WORKERS). safe-fetch: solo https (EXTENSION_ALLOW_HTTP=truepermite http), sin credenciales en la URL, bloquea localhost/.local/.internal, rangos privados, CGNAT, link-local (metadata 169.254.x.x) y equivalentes IPv6; valida la IP en el momento de conectar (sin ventana de DNS rebinding); máximo 3 redirecciones revalidadas; timeout 10 s; respuesta máx. 5 MB.- Las extensiones solo leen variables declaradas con
ENV_READ; los nombres reservados (DATABASE_URL,B2_*,NEXTAUTH_*,DATA_ENCRYPTION_KEY,RESEND_*…) están vetados.
No es una frontera fuerte
Un escape del vm compartiría proceso, sistema de ficheros y red con el backend. Para código hostil hace falta contenedor o microVM. Ver Modelo del sandbox y Extensiones.
Modelo del sandbox y código de terceros#
El sandbox es un worker_threads Worker con un contexto node:vm. `vm` no es una frontera de seguridad: cualquier objeto, función, Promise o Error del proceso que llegue al código de la extensión expone su constructor y, con él, process y require. Por eso el diseño impone una regla: solo primitivos cruzan la frontera (strings y números; el resto viaja como JSON serializado y parseado dentro del vm), las excepciones del host se convierten en Errores del vm, las Promises del host se envuelven en Promises del vm, los stack traces no contienen frames, y import(), eval, new Function, WebAssembly, SharedArrayBuffer y Atomics están bloqueados o eliminados. Cada vector tiene una prueba de ataque en el backend (tests/sandbox-escape.test.mjs).
Límite honesto
Aun así, vm + Worker no es aislamiento de seguridad completo: una vulnerabilidad nueva del motor o un fallo de la frontera daría acceso al proceso del backend compartido (y, por tanto, a los dominios de otros inquilinos). Los límites de heap, CPU y mensajes acotan el abuso de recursos, pero el Worker no es un sandbox del sistema operativo y la memoria fuera del heap no se acota.
- Capas que compensan el límite: revisión humana de cada versión, análisis estático al publicar (rechaza
import(),eval,fs,child_process…), permisos mínimos declarados, límites de recursos más estrictos paradev.*(48 MB de heap, red solo anetwork.hosts, sin tokens de proveedor), y kill switch inmediato (*yank*/suspensión) que corta la ejecución en la siguiente resolución. - Publicación de terceros desactivada por defecto: la variable del backend
MARKETPLACE_THIRD_PARTY_ENABLEDdebe valer exactamentetruepara que/api/developer/**acepte envíos, publicaciones y pruebas. Apagada, esas rutas responden 503marketplace_disabled, lasdev.*existentes solo se ejecutan en el dominio de su autor y desaparecen del catálogo público. Las extensiones de la plataforma (core-*) y sus pagos no cambian. - Recomendación: antes de abrir el marketplace a terceros sin revisión estricta, migrar a un intérprete aislado por WebAssembly (p. ej. QuickJS compilado a WASM) que no comparta objetos con el host. Plan de migración en
bloomx-extensions/_shared/SANDBOX-HARDENING.md.
Extensiones: validación, preferencias y obligatorias#
- Validación al publicar: el backend compartido valida manifest y UI (espejo de
ui-schema.ts, con límites de nodos, profundidad y tamaño) en todos los caminos de publicación e instalación. Un UI inválido no se guarda: 400 (estructura) o 422 (UI o límites) con la ruta del error. Los scriptssync-extensions.mjsyscripts/sync-repository-extensions.mjsfallan cerrados si no pueden ejecutar el validador. - Preferencias por usuario: la lista
disabledExtensionsviaja dentro del cuerpo firmado (Ed25519) dehooks/execute, se lee en el servidor de los ajustes cifrados del usuario y se sanea en ambos lados (ids válidos, sin duplicados, máx. 200). Una lista manipulada solo puede apagar extensiones para ese mismo usuario. - Obligatorias:
mandatory: trueen el manifest o política del dominio (settings.meta.mandatory, solo el manager dueño del dominio conPOST /api/manager/extensions/mandatory; auditado). El backend las ejecuta siempre, sin depender de lo que envíe el navegador; la interfaz no permite desactivarlas. DLP se publica como obligatoria. - Dominios legados sin firma: no reciben ni se les aplica la lista; se ejecuta todo como antes (compatibilidad).
Límites conocidos#
Acceso de administrador y backend compartido
requireAdmin (src/lib/admin-auth.ts) solo concede acceso a api/admin/* a (a) un "manager" del backend que sea propietario del dominio de esta instancia, o (b) un usuario con email en ADMIN_EMAILS y sesión con mfa:true. Para (a), el frontend consulta al backend con la cookie auth_session del propio manager (/api/auth/me y GET /api/manager/domains, que solo devuelve sus dominios) y exige que uno sea el dominio activo de la instancia (TOP_DOMAIN / host de NEXT_PUBLIC_APP_URL, nunca cabeceras del cliente). Aunque el registro de managers es abierto, un manager ajeno recibe 403 en api/admin/* y POST /api/admin/login no le reenvía la sesión. El resultado se cachea 45 s por sesión y falla cerrado: si el backend no responde o no hay dominio configurado, no se concede acceso por esta vía. El propietario no necesita MFA por esta vía (la sesión de manager es la del backend); la vía (b) sí lo exige. El backend vuelve a comprobar la propiedad (Domain.managerId) en cada ruta de manager que recibe un dominio (extensions/install|uninstall, extension/settings, domain-key).
AccountManager(multicuenta) guarda un JWT por cuenta enlocalStorage(bloomx_accounts): legible por cualquier XSS. Mitigado por TTL, tope absoluto y revocación porjti.- El callback de Google no exige TOTP a un usuario que activó MFA voluntariamente (sí lo bloquea si MFA es obligatorio). El login con credenciales de NextAuth sí lo bloquea.
- El anti-replay por nonce del protocolo de firma vive en memoria de cada instancia: en varias instancias un replay dentro de la ventana de 120 s contra otra instancia podría pasar.
- Sin
WEBHOOK_SECRET/RESEND_WEBHOOK_SECRETlos webhooks de Resend se aceptan sin firma (decisión de cada organizador; solo hay un aviso en consola). - Sin KMS/HSM: las claves viven en variables de entorno. Los correos y adjuntos no se cifran a nivel de aplicación.
- El rate limit del backend es siempre en memoria (sin Redis).
- El antivirus es opcional y falla abierto por defecto; archivos > 25 MB no se escanean.
- La política de contraseñas del backend (mínimo 8, Scrypt) es más débil que la del frontend; el backend no tiene MFA.
- Sealer: la clave viaja en el enlace del correo (ver Sealer).