Anlass
MenkyoKit ist unsere Lernplattform für die japanische Führerscheinprüfung, gebaut für ausländische Einwohner Japans: Next.js 14, Supabase, Stripe, der Großteil des Codes von einem KI-Agenten auf der Kommandozeile geschrieben. Dann sahen wir ein Video über eine KI-gebaute Website, die wenige Tage nach dem Launch komplett abgezogen wurde: 1,5 Millionen Tokens zur Kontoübernahme, 35.000 E-Mail-Adressen, 4.000 private Nachrichten. Der Angreifer benutzte kein Werkzeug. Er klickte rechts und las den Quelltext.
Das Video fasst die typischen Fallen des Vibe Coding in fünf Prüfungen zusammen, für die man keinen Code schreiben muss. Wir haben unsere eigene Seite noch am selben Tag durch alle fünf geschickt. Hier ist der Ablauf und das Ergebnis.
Die fünf Prüfungen
| Prüfung | So haben wir getestet |
|---|---|
| 1 · Lässt sich die Datenbank ohne Login lesen? | Mit dem öffentlichen Frontend-Schlüssel direkt gegen die Supabase-REST-API, jede Tabelle lesen und schreiben |
| 2 · Stecken Geheimnisse im Quelltext? | Alle JS-Bundles der Startseite geladen (13 Dateien, ca. 840 KB) und nach sk_live, whsec_, service_role und Preis-IDs gegrept |
| 3 · Kann jemand endlos hämmern? | Jede API-Route gelesen und nach Rate-Limiting-Code gesucht |
| 4 · Die Zahl in der URL um eins erhöhen | Jeden Pfad und Endpunkt aufgelistet, der per ID lädt, und geprüft, ob er Besitz verifiziert und nicht nur den Login |
| 5 · Im Inkognito-Fenster direkt hineinspazieren | Admin-Seiten, Dev-Tools, Kontoseite, Bezahlseiten und alle Endpunkte ohne Cookies aufgerufen |
Ergebnisse
Prüfung 1 · Bestanden
Alle acht Tabellen haben Row Level Security aktiv. purchases, entitlements, user_progress, mock_attempts, page_views, route_geometry, redeem_codes und admins mit dem anonymen Schlüssel zu lesen liefert jedes Mal ein leeres Array; anonyme Inserts weist die Datenbank ab (Fehler 42501). Jede Policy für angemeldete Nutzer lautet auth.uid() = user_id, man erreicht nur die eigenen Zeilen. Die Tabellen für Einlösecodes, Statistik und Routengeometrie haben gar keine Policies; nur der Serverschlüssel kommt an sie heran.
Prüfung 2 · Bestanden
Der einzige Schlüssel im Bundle ist der Publishable Key von Supabase, der für Browser gedacht ist und genau das darf, was RLS erlaubt. Kein Stripe-Secret, kein Webhook-Signaturschlüssel, kein Service-Role-Key, keine Preis-IDs.
Ein Schönheitsfehler: Der Variablenname SUPABASE_SERVICE_ROLE_KEY taucht im Bundle auf. Next.js inlined keine Umgebungsvariablen ohne NEXT_PUBLIC_-Präfix, der Wert ist undefined, nichts ist geleakt. Es zeigt aber, dass das Modul, das den Serverschlüssel liest, von Browser-Code importiert wird und getrennt gehört.
Prüfung 3 · Lücke
Nirgends gab es Rate Limiting. Alle drei öffentlichen Endpunkte ließen sich unbegrenzt aufrufen: Das Seitenaufruf-Beacon konnte jedes Skript endlos beschreiben; Codes einlösen und Stripe-Checkout-Sessions anlegen erfordern Login, aber nichts begrenzt einen angemeldeten Aufrufer. Login und Registrierung laufen direkt gegen Supabase Auth mit dessen eigenen Limits und berühren unsere API nicht.
Prüfung 4 · Bestanden
Keine URL lädt Daten per Nutzer-ID oder Bestellnummer. Die Kontoseite fragt serverseitig nur den angemeldeten Nutzer ab, RLS ist die zweite Schicht. Die Admin-Aktionen zum Freischalten von Features und zum Ausgeben oder Löschen von Codes prüfen bei jedem Aufruf erneut die Admin-Tabelle. Die Routen-Editor-API validiert das ID-Format und ist nur für Admins. Die Erfolgsseite nach der Zahlung trägt keine Session-ID.
Prüfung 5 · Bestanden
Ohne Cookies: Die drei Admin-Seiten und die Routen-Editor-API liefern 404 (nicht 403, sie verraten sich nicht); Dev-Seiten und -Endpunkte sind in Produktion 404; die Kontoseite leitet zum Login; Checkout und Einlösen liefern 401; der Stripe-Webhook ohne Signatur 400. Bezahlte Lektionen und die 50-Fragen-Prüfung rendern die Paywall auf dem Server; weder HTML noch RSC-Payload enthalten Lektionstext oder Fragen.
Wie wir das Rate Limiting eingebaut haben
Wir wollten dafür keinen weiteren Drittanbieter, und ein Ausfall des Limiters sollte keine Zahlung blockieren. Es wurden zwei Schichten.
Speicherschicht. Jede Vercel-Instanz hält eine eigene Map mit den jüngsten Zeitstempeln pro IP. Kostenlos und sofort, aber nicht zwischen Instanzen geteilt, eine Flut wird also nur durch die Instanzzahl geteilt. Das schützt das Statistik-Beacon: Überschüssige Anfragen werden still verworfen und bekommen trotzdem 204, ein Skript merkt nicht, dass es begrenzt wurde.
Datenbankschicht. Eine rate_limits-Tabelle in Supabase plus eine security definer-Funktion, die in einem Upsert „bei abgelaufenem Fenster zurücksetzen, sonst hochzählen" erledigt und zurückgibt, ob das Limit überschritten ist. Nur service_role darf sie ausführen; anonyme und angemeldete Aufrufer werden abgewiesen. Das schützt Einlösen und Checkout und zählt über alle Instanzen hinweg.
insert into rate_limits (key, count, window_start)
values (p_key, 1, now())
on conflict (key) do update set
count = case when rate_limits.window_start < v_cutoff then 1
else rate_limits.count + 1 end,
window_start = case when rate_limits.window_start < v_cutoff then now()
else rate_limits.window_start end
returning count into v_count;
return v_count <= p_limit;
Schwellen: Beacon 30 pro IP und Minute; Einlösen 10 pro Nutzer und 30 pro IP je zehn Minuten; Checkout 6 pro Nutzer je zehn Minuten. Fehlt die Funktion oder ist die Datenbank nicht erreichbar, lässt der Limiter durch. Ein Limiter-Ausfall darf nie zum Zahlungsausfall werden.
Nach dem Deploy auf der Produktionsdatenbank geprüft: Derselbe Schlüssel mit Limit 2 lieferte über drei Aufrufe true, true, false; der Aufruf mit dem anonymen Schlüssel ergab permission denied; anonymes Lesen der Tabelle lieferte nichts.
Eine Zahl
Vom Ende des Videos bis zur bestätigten Produktion dauerten Audit und Fix etwa zwei Stunden. Die Prüfungen selbst brauchten fast keinen Code: ein curl, ein grep, ein Inkognito-Fenster.
Für alle, die ebenfalls mit KI bauen
- RLS einzuschalten ist der Anfang. Lest jede Tabelle wirklich mit dem anonymen Schlüssel.
- Ladet die JS-Bundles herunter und grept sie. Hört nicht beim HTML auf.
- „Login erforderlich" heißt nicht „begrenzt". Auch Endpunkte hinter dem Login brauchen Rate Limiting.
- Blockiert geschützte Seiten auf dem Server. Liefert den Inhalt nicht aus, um ihn mit CSS zu verstecken.
- Baut den Limiter fail-open, sonst wird er euer zerbrechlichster Single Point of Failure.
Quellvideo: 小袋鼠KAKO, „AI 做完的网站,直接上线安全吗?上线前你必须做的 5 个检查".