It's a Plan: Dauerhafter Kontozugriff durch Passkey-Einschreibung per API-Schlüssel
Wer einen persönlichen API-Schlüssel hält, schreibt einen Passkey auf das Konto des Eigentümers ein und meldet sich nach Passwortrücksetzung und Widerruf aller Schlüssel weiter an.
Advisory-ID: TP-2026-124
Produkt: It's a Plan (selbst gehostete Projektverwaltung und Issue-Tracking mit KI-Agenten)
Schwachstellentyp: Unzureichende Authentifizierung (CWE-287)
CVE: nicht beantragt
CVSS 3.1: 8.1 (Hoch) · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N
Betroffene Versionen: < 1.7.0
Hersteller-Advisory: GHSA-54vc-ww62-5mhj
Behoben in: 1.7.0
Gemeldet: 11. Oktober 2026
Zusammenfassung
It's a Plan ist eine selbst gehostete Projektverwaltung, deren Konten sich mit Passwort, Passkey oder einem persönlichen API-Schlüssel ansprechen lassen. Ein persönlicher API-Schlüssel löst zur Sitzung seines Eigentümers auf, und die Passkey-Endpunkte nehmen ihn an, sodass ein Request mit x-api-key und ohne Sitzungscookie einen neuen Passkey auf dem Konto einschreibt. Derselbe hooks.before-Block, der /api-key/create mit x-api-key abweist, enthält für /passkey/* keine solche Klausel. Die Frischeprüfung der Passkey-Endpunkte läuft ins Leere, weil das Plugin die Sitzung eines Schlüssels bei jedem Request mit dem aktuellen Zeitstempel baut. Wer einen geleakten API-Schlüssel hält, meldet sich deshalb auch nach Passwortrücksetzung und Widerruf aller Schlüssel weiter als Kontoinhaber an.
Ursache
Das Passkey-Plugin ist unter einem Kommentar registriert, nach dem ein Passkey einem bereits angemeldeten Konto hinzugefügt wird (packages/auth/src/index.ts:618). Der hooks.before-Block weist /api-key/create ab, wenn der Request x-api-key trägt, und ergänzt keine Klausel für /passkey/* (packages/auth/src/index.ts:526). Die Regel, die dieser Block durchsetzen soll, steht in packages/auth/AGENTS.md:208: "The expiry only holds if a key cannot renew itself". @better-auth/api-key 1.6.27 baut das Sitzungsobjekt des Schlüssels bei jedem Request mit createdAt: new Date() (dist/index.mjs:2437), sodass freshSessionMiddleware auf den Registrierungsendpunkten nie eine alte Sitzung sieht. Der eingeschriebene Passkey ist eine eigene Zeile ohne Ablaufspalte und bleibt von revokeSessionsOnPasswordReset und api-key/delete unberührt.
Proof of Concept
POST /api/auth/api-key/create x-api-key: <key>
-> 403 {"message":"An API key cannot create another API key. Sign in to create one."}
GET /api/auth/passkey/generate-register-options x-api-key: <key>
-> 200 {"challenge":"…","rp":{"name":"It's a Plan", …}}
POST /api/auth/passkey/verify-registration x-api-key: <key> + Zeremonie-Cookie
-> 200 {"name":"attacker-passkey", …,"userId":"<victim-id>"}
Opfer: Passwortrücksetzung + Löschen aller API-Schlüssel
-> Anmeldung per Passkey liefert weiterhin eine Sitzung des Opfers
Der API-Schlüssel gilt für die Passkey-Endpunkte als vollwertige, stets frische Sitzung, und der daraus entstandene Passkey hängt nicht am Schlüssel. Aus der Passkey-Sitzung legt der Angreifer anschließend einen neuen API-Schlüssel mit frischer Laufzeit an, was der Schlüssel selbst nicht darf.
Auswirkung
- Ein geleakter persönlicher API-Schlüssel wird zu einem Anmeldenachweis, den die dokumentierte Wiederherstellung nicht entfernt.
- Der eingeschriebene Passkey läuft nicht ab, während der Schlüssel, aus dem er stammt, nach höchstens 365 Tagen abläuft.
- Eine Sitzung über den Passkey legt neue API-Schlüssel mit frischer Laufzeit an.
- Für die Einschreibung wird keine E-Mail versendet; der Passkey erscheint nur unter Konto → Sicherheit mit einem vom Angreifer gewählten Namen.
Referenzen
Steckt so etwas in Ihrer Software?
Diese Schwachstelle hat unser Team im Rahmen seiner Arbeit gefunden. Lassen Sie Ihre Anwendungen von denselben Spezialisten prüfen, mit einem Penetrationstest von turingpoint.
