Seguridad

Modelo de amenazas, reglas, doctrina
Sistema operativo

SECURITY — Fuego Social

Doctrina de seguridad. Es ley para todo lo que se construye en este proyecto.
Tan vinculante como brand.config.ts y BRAND.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

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

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

  1. El actor_id viene 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.

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

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

  1. CORS por allowlist, no wildcard. ALLOWED_ORIGINS en .env define

exactamente que origenes pueden hablar con la API. Nunca *.

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

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

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

  1. 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_access y fs_refresh son HttpOnly, 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_attempts registra 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_sub unique en users. Si el sub ya existe → login. Si no → signup

enlazado al email verificado por Google.

  • Validar aud contra GOOGLE_CLIENT_ID. Validar el iss de 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_log y bookings.guest_ip con 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 hace require

de ahi.

  • connectionTimeoutMillis: 5000 para detectar DB caida rapido.
  • Transacciones criticas (booking, refund, review) usan tx() con FOR

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

BucketLimiteVentana

|---|---|---|

Global por IP120 req60s
Login por IP10 intentos15 min
Signup por IP5 intentos15 min
Booking por usuario2060 min
Hub por IP240 req60s

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: true solo 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)

HeaderValor

|---|---|

Strict-Transport-Securitymax-age=31536000; includeSubDomains; preload
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policygeolocation=(), camera=(), microphone=(), payment=(self), usb=()
Cross-Origin-Opener-Policysame-origin
Cross-Origin-Resource-Policysame-site
X-Powered-By(vacio — ocultamos el stack)

Webhooks (Stripe / MercadoPago)

Cuando se integren los pagos:

  1. Endpoint dedicado por proveedor: /api/webhooks/stripe,

/api/webhooks/mercadopago.

  1. Verificar la firma con el secret del proveedor antes de leer el body.
  1. Insertar en webhook_events (provider, event_id, ...). La unicidad

(provider, event_id) deduplica replays.

  1. Procesar idempotente. Si la booking ya esta confirmed, no la "confirmamos

de vuelta".

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

  1. Detectar — alertas automaticas sobre audit_log (auth.login.denied

spike, refresh reuse, etc.).

  1. ContenerUPDATE users SET is_active = FALSE en bulk si es ATO

masivo. Revocar todas las sesiones (UPDATE sessions SET revoked_at = NOW()).

  1. Erradicar — rotar JWT_SECRET y COOKIE_SECRET invalida todos los

tokens de un golpe.

  1. Recuperar — reset password forzado a usuarios afectados.
  1. Comunicar — notificacion clara a usuarios afectados, segun GDPR si

aplicara.

  1. 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 o requireAdmin si 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() — no e.message directo

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)