SECURITY — Fuego Social
Doctrina de seguridad. Es ley para todo lo que se construye en este proyecto.
Tan vinculante comobrand.config.tsyBRAND.md. Si una nueva feature
rompe alguna de estas reglas, la feature se rechaza o se rediseña.
>
Ultima actualizacion: 2026-04-27
Modelo de amenazas
Fuego Social maneja identidad personal, direcciones fisicas privadas,
dinero real y reputacion bidireccional. Pensamos en escala Airbnb desde
el dia uno. El blast radius de un incidente es:
- Doxxing: revelacion de direccion exacta antes de confirmar booking
- Robo financiero: cobro fraudulento, payouts a cuentas equivocadas
- Cuenta tomada (ATO): credential stuffing, sesiones robadas
- Manipulacion de reputacion: reviews falsas, sabotaje a hosts
- Disponibilidad: DoS contra busqueda y booking en horarios pico
- Confidencialidad: filtrado de PII (email, telefono, direccion)
Todas las decisiones tecnicas tienen que considerar estas amenazas.
Las 10 reglas no negociables
- Ningun secreto en codigo. Todas las credenciales viven en
.env(que
esta en .gitignore). El codigo solo lee de process.env via lib/env.js.
Si encontras una password, key o token en un .js, .ts, .astro o
.sql versionado, eso es un bug — moverlo a .env ya.
- Toda escritura requiere autenticacion. Cualquier endpoint que crea,
modifica o borra datos llama a auth.requireAuth(req) o
auth.requireAdmin(req) antes de tocar la DB. Sin excepciones.
- El
actor_idviene del JWT, NUNCA del body. Un cliente jamas puede
declarar quien es. req.user.id lo determina el middleware desde el token
verificado. Si ves un guest_id o user_id leido del body, eso es una
vulnerabilidad de impersonation.
- SQL siempre parametrizado. Nunca concatenar strings en queries. Usar
query("... WHERE x = $1", [val]). Para identificadores dinamicos (nombres
de columna), validar contra una whitelist explicita.
- Validacion en la frontera. Cada endpoint valida su input con un schema
de lib/validators.js (zod). Tipos, rangos, longitudes, patrones. El
handler asume que el input ya esta limpio.
- CORS por allowlist, no wildcard.
ALLOWED_ORIGINSen.envdefine
exactamente que origenes pueden hablar con la API. Nunca *.
- Headers de seguridad en cada respuesta. CSP, HSTS, X-Frame-Options,
X-Content-Type-Options, Referrer-Policy, Permissions-Policy.
Aplicados por sec.securityHeaders(res) automaticamente desde lib/security.js.
- Rate limit en cada superficie. Global por IP, especifico por accion
sensible (login, signup, booking, write). Implementado en
sec.rateLimit(bucket, limit, window). Si agregas un endpoint, agregale un
rate limit.
- Audit log en cada accion sensible.
sec.audit(req, "action.name", {...})
despues de cada login, signup, booking, refund, cambio de password, accion
de admin. La tabla audit_log es append-only — nunca borrar filas.
- Errores no filtran detalles internos. Para 5xx la respuesta dice
"Something went wrong". Stack traces, mensajes de pg, paths — todo va al
log del servidor, nunca al cliente. sec.handleError() lo hace solo.
Arquitectura de auth
Tokens
- Access token — JWT HS256, 15 minutos, firma con
JWT_SECRET.
Contiene sub (user id), email, admin. iss y aud validados.
- Refresh token — opaco (48 bytes random base64url), 30 dias, hasheado
en la DB (HMAC-SHA256 con COOKIE_SECRET). El plaintext nunca se persiste.
Rotacion en cada uso: cada /auth/refresh emite uno nuevo y revoca el
anterior. Si llega un refresh ya revocado, **se cortan TODAS las sesiones
del usuario** (deteccion de reuso = posible robo de token).
Cookies
fs_accessyfs_refreshsonHttpOnly,Secure,SameSite=Lax.
- El frontend tambien puede usar
Authorization: Bearer <jwt>para mobile/SPA.
Password
- bcrypt con
BCRYPT_ROUNDS=12.
- Minimo 10 caracteres. Sin maximo absurdo (max 128).
- Tabla
login_attemptsregistra cada intento (email, ip, ua, success).
- Despues de 10 intentos fallidos en 15 minutos: cuenta bloqueada 30 minutos
(users.locked_until).
Google OAuth (cuando se integre)
google_subunique enusers. Si el sub ya existe → login. Si no → signup
enlazado al email verificado por Google.
- Validar
audcontraGOOGLE_CLIENT_ID. Validar elissde Google.
Datos sensibles
Direccion exacta del listing
Solo se revela despues de que la booking esta confirmed.
getListingBySlug()devuelve la fila completa, pero
redactListingForPublic() quita address para cualquier viewer que no sea
el propio host.
- En el cliente, la pagina del listing solo pide la direccion despues del
confirm. La API debe enforcar lo mismo del lado servidor — nunca confiar en
el cliente.
PII en logs
- No loguear emails, nombres ni direcciones en logs de aplicacion.
- En
audit_log, los IDs (UUIDs) son suficientes — no copiar PII al metadata.
- IPs: las guardamos en
audit_logybookings.guest_ipcon proposito de
fraude/abuse. Politica de retencion: 90 dias, despues anonimizar (tarea
scheduled pendiente).
Pagos
- Nunca persistir numero de tarjeta, CVV ni nada que toque PCI scope. Solo
los IDs de Stripe / MercadoPago (stripe_payment_intent, etc.).
- Webhooks de pago deben verificar la firma del proveedor antes de tocar
la DB. Tabla webhook_events deduplica por (provider, event_id).
SQL & DB
- Una sola pool de conexiones, en
lib/db.js. Todo lo demas hacerequire
de ahi.
connectionTimeoutMillis: 5000para detectar DB caida rapido.
- Transacciones criticas (booking, refund, review) usan
tx()conFOR
UPDATE en filas que se modifican (race conditions = doble booking).
- Cuando migremos a Supabase / RDS:
- sslmode=require obligatorio
- Read replicas para queries de listing/search; escrituras siempre al
primary
- Backups diarios + PITR (Point-In-Time Recovery)
- Audit log replicado a otra region
Rate limits actuales
| Bucket | Limite | Ventana |
|---|---|---|
| Global por IP | 120 req | 60s |
| Login por IP | 10 intentos | 15 min |
| Signup por IP | 5 intentos | 15 min |
| Booking por usuario | 20 | 60 min |
| Hub por IP | 240 req | 60s |
Configurables via .env (RL_*). Cuando lleguemos a CDN/Cloudflare, una
segunda capa de rate limit a nivel edge se suma a esto.
CORS
- Allowlist en
ALLOWED_ORIGINS. Origenes desconocidos no reciben los headers
Access-Control-Allow-*, asi que el browser bloquea el request.
Access-Control-Allow-Credentials: truesolo cuando el origen matchea
exactamente — esto es lo que hace que las cookies se envien.
CSP (Content Security Policy)
``
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com data:;
img-src 'self' data: https:;
connect-src 'self' https://pompas.saypeter.com https://fuegosocial.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
upgrade-insecure-requests;
`
unsafe-inline en style-src es transicional — el hub y la web tienen estilos
inline. Cuando migremos a CSS externo + nonces, lo sacamos. **Scripts inline
estan prohibidos** desde ya.
Headers de seguridad (otra vez, todos)
| Header | Valor |
|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains; preload |
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | geolocation=(), camera=(), microphone=(), payment=(self), usb=() |
Cross-Origin-Opener-Policy | same-origin |
Cross-Origin-Resource-Policy | same-site |
X-Powered-By | (vacio — ocultamos el stack) |
Webhooks (Stripe / MercadoPago)
Cuando se integren los pagos:
- Endpoint dedicado por proveedor: /api/webhooks/stripe
,
/api/webhooks/mercadopago.
- Verificar la firma con el secret del proveedor antes de leer el body.
- Insertar en webhook_events (provider, event_id, ...)
. La unicidad
(provider, event_id) deduplica replays.
- Procesar idempotente. Si la booking ya esta confirmed
, no la "confirmamos
de vuelta".
- Responder 200 rapido (< 5s) o el proveedor reintenta. Procesamiento pesado
va a una queue.
Idempotency
POST /api/bookings acepta header X-Idempotency-Key (UUID generado por el
cliente). Si llega un segundo request con el mismo key del mismo usuario,
devolvemos la booking original — no creamos una duplicada. Critico para
clientes flakey de mobile.
Admin
- users.is_admin
es la unica fuente de verdad. Nunca verificar admin por
email hardcodeado.
- Acciones de admin pasan por auth.requireAdmin()
y SIEMPRE generan audit
log con metadata que describe el cambio.
Datos en transito
- Toda comunicacion publica via HTTPS (Cloudflare → Cloudflared tunnel →
servidor). HSTS preload garantiza que browsers nunca degraden a HTTP.
- WebSockets (cuando se sumen para chat en vivo) usan WSS, mismo origen,
misma auth via cookie.
Datos en reposo
- DB encriptada at-rest (PostgreSQL local: cifrado de disco del VM en
produccion; Supabase/RDS lo proveen managed).
- Backups encriptados.
- password_hash
con bcrypt (no recuperable).mfa_secret(cuando se sume)
cifrado con COOKIE_SECRET derivada.
MFA (roadmap)
Estructura ya en DB (users.mfa_secret, users.mfa_enabled). Cuando se
implemente:
- TOTP (Google Authenticator, 1Password, etc.)
- Backup codes
- Obligatorio para cuentas con is_admin = true
y para hosts que recibieron
> $1000 en payouts.
Plan de respuesta a incidentes
- Detectar — alertas automaticas sobre audit_log
(auth.login.denied
spike, refresh reuse, etc.).
- Contener — UPDATE users SET is_active = FALSE
en bulk si es ATO
masivo. Revocar todas las sesiones (UPDATE sessions SET revoked_at = NOW()).
- Erradicar — rotar JWT_SECRET
yCOOKIE_SECRETinvalida todos los
tokens de un golpe.
- Recuperar — reset password forzado a usuarios afectados.
- Comunicar — notificacion clara a usuarios afectados, segun GDPR si
aplicara.
- Aprender — postmortem, agregar al SECURITY.md.
Como sumar una feature nueva sin romper la doctrina
Antes de mergear cualquier endpoint nuevo:
- [ ] Lee de req.user
(no del body) cuando necesita identidad
- [ ] Tiene un schema en lib/validators.js
- [ ] Llama a requireAuth
orequireAdminsi modifica datos
- [ ] Verifica ownership antes de leer/modificar (guest_id vs req.user.id)
- [ ] Usa SQL parametrizado, nunca concatenacion
- [ ] Tiene rate limit propio si es escritura
- [ ] Llama a sec.audit()
si es accion sensible
- [ ] No agrega secretos al codigo
- [ ] Si usa un origen externo nuevo, lo agrega a CSP / CORS allowlist
- [ ] Manejo de errores via sec.handleError()
— noe.messagedirecto
Si todas las casillas marcan, esta listo.
Como verificar que todo esto sigue de pie
`bash
Headers en API
curl -sSI https://pompas.saypeter.com/api/health
CORS denegado
curl -sSI -H "Origin: https://evil.com" https://pompas.saypeter.com/api/health
Auth requerida
curl -sS -X POST https://pompas.saypeter.com/api/bookings -d '{}' # debe dar 401
Rate limit (rapido, simple loop)
for i in $(seq 1 130); do curl -s -o /dev/null -w "%{http_code} " https://pompas.saypeter.com/api/health; done
Las ultimas deben dar 429
`
Pendientes (security backlog)
- [ ] CSP sin unsafe-inline
(mover estilos inline a CSS files + nonces)
- [ ] MFA TOTP para admin y hosts con payouts > $1000
- [ ] Anonimizacion automatica de IPs en audit_log
despues de 90 dias
- [ ] Soft-delete + retencion de 30 dias antes de purga real
- [ ] Bug bounty program (despues de tener trafico real)
- [ ] WAF rules en Cloudflare (cuando se conecte el dominio)
- [ ] Pen test externo antes de salir publicos en serio
- [ ] SCA (npm audit
+osv-scanner) en CI
- [ ] SAST (semgrep
) en pre-commit
- [ ] Rotacion automatica de JWT_SECRET` cada 90 dias (con grace window)