Seguridad · En uso
Security Standard
Orquestador de seguridad en 3 fases (build → apply → test) con checklist P0-P3 y playbooks por stack.
- OWASP Top 10
- Postgres RLS
- OAuth 2.0 / JWKS
- PCI SAQ A
- DevSecOps (SAST/DAST/SCA)
No es un addon al final del proyecto: es un orquestador que secuencia tres fases (build → apply → test) y enruta a un checklist universal P0–P3 más tres playbooks por stack. La carne está en los detalles: RLS
USINGvsWITH CHECK,app_metadatavsuser_metadata, el bypass de middleware de Next.js (CVE-2025-29927), y la diferencia entre SAQ A y SAQ D en PCI.
Stack/dominio: OWASP Top 10 · Postgres RLS · OAuth 2.0 client-credentials / JWKS · HMAC-SHA256 webhooks · PCI SAQ A · DevSecOps (SAST/DAST/SCA) · Estado: en uso en producción, aplicado por defecto en mis builds · Rol: autor
Por qué existe
La seguridad falla cuando es un paso opcional que alguien recuerda al final. Lo volví un estándar no-opcional: cualquier build o modificación de web, PWA, portal B2B o API arranca las tres fases por defecto, escalando profundidad al riesgo (un sitio estático informativo va ligero; un portal B2B con pagos y PII va completo). En vez de recordar cuál de cinco documentos leer, hay un único entrypoint que decide qué corre en cada fase del ciclo de vida.
El contexto es LATAM real: DTC + portales B2B chicos, pasarelas locales (Wompi en Colombia, MercadoPago), ERPs de cliente que consumen una API S2S, y mucho self-host sobre VPS/Docker. El threat model se arma alrededor de eso, no de un enterprise genérico.
Qué incluye
Cinco piezas que se referencian entre sí:
- Orquestador (entrypoint único). Secuencia
BUILD → APPLY → TEST. En BUILD aplica reglas de stack mientras se escribe el código; en APPLY corre el checklist P0–P3 antes de cada hito/PR/deploy; en TEST estratifica el scanning (pre-commit → PR/CI → nightly → pre-handoff DAST) para no saturar. - Checklist universal P0–P3. Agnóstico de stack (Next.js, Astro, Django, Firebase, Cloudflare). 16 secciones (A–P): secrets, transport/headers, authn, authz, identificadores y aleatoriedad, input validation, rate limiting, logging, supply chain, frontend, API design, DB, PWA, compliance, hardening de VPS/self-host, y deploy/rollback + costos de APIs de terceros. P0 bloquea el deploy; P1 antes de go-live; P2/P3 backlog.
- Toolkit (SAST/DAST/SCA/secretos). Comandos listos: Semgrep (rulesets por stack), gitleaks/trufflehog, osv-scanner, OWASP ZAP + nuclei + testssl para DAST, schemathesis para fuzzear la API contra su OpenAPI. Estratificación de CI explícita y MCPs para correr scans desde el propio agente.
- Tres playbooks por stack: Next.js + Supabase + Vercel; Astro + Cloudflare Pages/Workers; y el portal B2B multi-tenant con API a ERP y pasarelas LATAM. Más de uno puede aplicar a la vez (un portal B2B en Next.js usa el de B2B + el de PWA).
Decisiones y detalles que muestran profundidad
RLS de Postgres: USING ≠ WITH CHECK. El error clásico en una policy de UPDATE es poner solo USING. Eso filtra qué filas ves, pero no qué valores puedes escribir, así que un usuario puede reasignar user_id a otro y mover el registro fuera de su alcance — escalada de privilegios silenciosa. La policy correcta separa las dos cláusulas:
create policy "update own" on orders for update
using (user_id = (select auth.uid()))
with check (user_id = (select auth.uid())); -- bloquea cambiar el ownership
El (select auth.uid()) además no es cosmético: envuelto en subselect, Postgres lo evalúa una vez (initplan) en lugar de por fila, y con un índice en la columna del tenant la policy escala.
app_metadata vs user_metadata en Supabase: vector de escalada real. user_metadata es editable por el propio cliente con supabase.auth.updateUser({ data: { role: 'admin' } }). Si tus policies leen el rol de ahí, cualquiera se hace admin desde la consola del navegador. El rol vive en app_metadata (solo escribible con service_role, server-side) y se lee desde el claim firmado del JWT:
create policy "admin all" on orders for all
using ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin');
El mismo principio gobierna el multi-tenancy por RLS: el tenant_id sale del JWT, nunca de un campo que el cliente controle.
CVE-2025-29927 (bypass de middleware de Next.js). El header x-middleware-subrequest permitía saltarse la lógica de autorización del middleware. La conclusión de diseño es más amplia que parchear la versión (≥ 15.2): la autorización en el middleware es optimización de UX, no control de seguridad. Por eso cada Server Action, Route Handler y Server Component que toca datos privados re-autoriza vía un Data Access Layer central (verifySession() / requireRole(), marcado import 'server-only', deduplicado con cache() por request). La identidad (userId/tenantId) se deriva siempre de la sesión server-side; nunca de un userId en el body — el patrón visto en el wild es un PATCH /user con userId en el cuerpo que se cambia a 1 para tomar la cuenta admin (IDOR). El playbook de Astro maneja sus propios CVEs análogos (CVE-2024-56140 CSRF, CVE-2026-25545 SSRF por Host injection).
Verificación de webhooks de pasarelas: firma antes de procesar, comparación constant-time. Un webhook entrante se valida con HMAC/checksum antes de tocarlo, con crypto.timingSafeEqual, nunca == (que filtra por timing). Wompi usa un checksum SHA-256 sobre las properties concatenadas en orden + timestamp + secret; MercadoPago, un HMAC-SHA256 sobre un manifest id:...;request-id:...;ts:...;. Wompi (mayúsculas en el hex):
const expected = createHash('sha256').update(signingString).digest('hex').toUpperCase()
if (!timingSafeEqual(Buffer.from(checksum, 'hex'), Buffer.from(expected, 'hex'))) {
return new Response('Invalid checksum', { status: 403 })
}
Y aun con firma válida: nunca confiar solo en el body. Se cruza contra la API del proveedor por el transaction_id para el estado real, con idempotencia por (provider, transaction_id) porque los proveedores reintentan.
PCI: SAQ A vs SAQ D — la decisión que ahorra una auditoría. Si la tokenización ocurre siempre en el cliente (Bricks de MP / Widget de Wompi) y el servidor nunca toca PAN ni CVV, el alcance es SAQ A (~20 controles, casi todos documentales). Si el servidor procesa o almacena PAN en cualquier momento, salta a SAQ D (~330 controles, auditoría anual cara). Regla dura: si un proveedor pide recibir la tarjeta cruda, se rechaza o se cobra el costo de SAQ D.
API a ERP: OAuth client-credentials + rotación de JWKS. Cada cliente recibe client_id/client_secret (hash argon2id/bcrypt cost 12+), y /oauth/token emite un JWT RS256 corto (30 min) con kid, scope (intersección con los scopes permitidos) y tenant_id. El consumidor verifica contra /.well-known/jwks.json con createRemoteJWKSet. La rotación es sin downtime: se publica la llave nueva, se espera la ventana de propagación (24h), se empieza a firmar con ella, y se retira la vieja después del TTL máximo del token. Para ERPs legacy hay HMAC-SHA256 con signing-string canónico, ventana de timestamp de 5 min y nonce anti-replay en Redis; para clientes regulados, mTLS encima vía Cloudflare API Shield (el cert prueba CF→origen, no qué cliente lógico es — por eso va combinado con OAuth/HMAC, no en lugar de).
Audit log append-only con trigger. La inmutabilidad no se deja a la convención: un trigger BEFORE UPDATE OR DELETE lanza excepción, así que la tabla solo acepta inserts. Para evidencia legal se replica a S3/R2 con Object Lock (WORM, modo COMPLIANCE).
mTLS vía Cloudflare API Shield. Root CA en CF, cert por cliente ERP, regla de edge que bloquea /api/erp/* si cf.tlsClientAuth.certVerified != "SUCCESS". En Vercel (sin mTLS nativo) la respuesta es poner Cloudflare delante.
Hardening de VPS, porque self-host = la infra es tu problema. Un VPS con IP pública recibe escaneo y diccionario SSH a los minutos de existir — no a la semana. La sección N cubre SSH solo por clave (PasswordAuthentication no, PermitRootLogin no, fail2ban), firewall default-deny con solo 80/443 expuestos, Postgres/Redis jamás en 0.0.0.0, no montar docker.sock en contenedores expuestos, y backups a almacenamiento externo con restore probado. Cierra con un bloque de verificación post-setup (ss -tulpn, ufw status, grep al sshd_config).
Qué demuestra
Que sé dónde están los filos reales, no la teoría de OWASP recitada. Distinguir USING de WITH CHECK, saber por qué user_metadata es un agujero de escalada, tratar el middleware como UX y no como authz, y elegir SAQ A conscientemente para no caer en SAQ D son decisiones de implementación, cada una con su línea de código o su policy. También que pienso en el ciclo completo: no solo “pasó el SAST”, sino rollback en un clic probado, spending cap en las APIs de IA (una key filtrada sin tope = factura de miles en horas), y un VPS que aguanta el primer escaneo. Y que conozco el terreno LATAM concreto: Wompi, MercadoPago, ERPs de cliente, Habeas Data.
Estado y siguiente paso
En uso. Las reglas de stack se aplican mientras codeo, el checklist es el gate antes de cada deploy, y el scanning corre estratificado en CI. Reglas duras que no se negocian: nunca DAST contra producción sin permiso escrito, nunca contra stores de Shopify (infra ajena + ToS), y coordinar antes de un scan agresivo detrás de WAF para no auto-banearme la IP.
El siguiente paso es el harness de DAST automatizado (dast-attack.sh) que encadena ZAP full scan + nuclei + testssl y agrega los reportes con prioridad P0–P3 y archivo:línea. Está sin construir a propósito: se arma y se prueba contra la primera staging real de cliente, con foco en fuga cross-tenant (IDOR) — el riesgo número uno de un portal B2B —, no a ciegas.