diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 4ae8cb2..2466a03 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -40,7 +40,7 @@ jobs: SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }} with: args: >- - -Dsonar.projectKey=My-Weekly-ToDo-List + -Dsonar.projectKey=mARTin-B78_My-Weekly-ToDo-List_e038f066-0063-4a1e-9c33-03fdd5ab3c3a -Dsonar.sources=src,scripts -Dsonar.exclusions=**/node_modules/**,**/.next/**,**/*.spec.ts -Dsonar.qualitygate.wait=true diff --git a/CHANGELOG.md b/CHANGELOG.md index e2cb817..ac8eaa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to this project are documented in this file. +## [1.117.0] - 2026-09-06 + +### Security +- Rate-Limiting für alle Auth-Endpunkte: `signup` (5/15 Min), `resend-verification` + (3/15 Min), `verify-email` (10/15 Min) und `reset-password` (5/15 Min), je Client-IP. + Bisher waren sie unbegrenzt aufrufbar — beliebig viele Mails über den eigenen + SMTP-Zugang, beliebig viele Rateversuche auf Verifizierungscodes. +- Passwort-Reset-Token wird nur noch als SHA-256-Hash gespeichert. Bisher lag er im + Klartext in der Datenbank: Lesezugriff auf die DB genügte, um fremde Konten zu übernehmen. +- Tote Mock-Login-Route `src/app/api/auth/login/route.ts` entfernt. Sie verglich gegen ein + hartcodiertes Fake-Passwort, erzeugte ein bedeutungsloses `mock-jwt-token-…` und war + über die Middleware-Ausnahme für `/api/auth/*` ohne Anmeldung erreichbar. Kein + Login-Bypass, aber sie protokollierte jede probierte E-Mail-Adresse. +- Passwort-Reset nutzt jetzt bcrypt-Kosten 12 statt 10 — gleicher Wert wie beim Signup. + +### Fixed +- `npm test` lief ins Leere: Das Skript rief `jest` auf, obwohl es weder eine Jest-Config + noch einen einzigen Jest-Test gibt. Zeigt jetzt auf Playwright; `jest`, `ts-jest` und + `@types/jest` sind entfernt (letzteres kollidiert mit den Playwright-Globals). +- Falscher `sonar.projectKey` im GitHub-Workflow — er hätte in ein zweites, leeres + SonarQube-Projekt geschrieben statt in das bestehende. + +### Changed +- Die zwölf Wegwerf-Skripte aus der Fehlersuche (`login-debug`, `css-debug-test`, …) liegen + jetzt unter `tests/debug/` und sind per `testIgnore` aus dem normalen Testlauf genommen. + Aktiv bleiben die drei echten Testdateien. Erreichbar über `npm run test:debug`. + +### Added +- `TODO.md`: abhakbare Liste aus dem Repository-Audit. +- `docs/secret-rotation.md`: Anleitung zum Austausch der Zugangsdaten, ohne dass ein Wert + auf dem Bildschirm erscheint. +- `scripts/quality-check.sh`: bündelt `npm audit`, Typecheck, Lint und den SonarQube-Scan + für den nächtlichen Lauf. `npm audit` fehlte bisher komplett. + ## [1.116.4] - 2026-09-06 ### Fixed diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..37af306 --- /dev/null +++ b/TODO.md @@ -0,0 +1,197 @@ +# To-do — Repository-Audit vom 2026-09-06 + +Abarbeitungsliste aus dem Repository-Audit. Reihenfolge ist bewusst gewählt: +schnelle Sicherheitsgewinne zuerst, das große Refactoring zuletzt. + +Abhaken mit `[x]`. Die Nummern in Klammern entsprechen den Punkten aus dem Audit. + +--- + +## Block A — Schnell erledigt, sofortiger Gewinn + +### A1. Tote Mock-Login-Route entfernen (Audit 3) +- [x] `src/app/api/auth/login/route.ts` löschen +- [x] Prüfen, dass niemand sie aufruft (`grep -rn "api/auth/login" src/`) +- [ ] Commit + Version bumpen + +**Warum:** Altcode aus der Anfangszeit. Vergleicht gegen ein hartcodiertes Fake-Passwort +(`$2b$10$examplehashedpassword`), erzeugt ein bedeutungsloses `mock-jwt-token-…` und setzt es +als Cookie. Weil die Middleware `/api/auth/*` bewusst durchlässt, ist die Route **ohne Login +von außen erreichbar** und schreibt jede probierte E-Mail per `console.log` ins Log. +Kein Login-Bypass (der Vergleich schlägt immer fehl), aber unnötige Angriffsfläche. + +### A2. `npm test` reparieren (Audit 6) +- [x] Entscheiden: Jest raus oder Jest einrichten +- [x] `package.json`: `"test"` auf das zeigen lassen, was wirklich existiert (Playwright) +- [x] `"test:e2e"` für Playwright ergänzen +- [x] Debug-Skripte in `tests/` aussortieren (siehe unten) + +**Warum:** `"test": "jest"` — aber es gibt **keine Jest-Config und keinen einzigen Jest-Test**. +Die 15 Dateien in `tests/` sind Playwright-Specs, und die Namen verraten den Charakter: +`login-debug.spec.ts`, `css-debug-test.spec.ts`, `login-simple-debug.spec.ts`. Wegwerf-Skripte, +keine Regressionstests. `npm test` läuft ins Leere. + +**Aufräumen:** Die reinen Debug-Skripte löschen, die brauchbaren behalten und benennen. + +### A3. `dev`-Branch auflösen (Audit 5) +- [ ] Die vier Maßnahmen aus `f6c4a21` frisch auf `main` anwenden (siehe A1, B1, B2) +- [ ] `dev` auf `main` zurücksetzen **oder** löschen +- [ ] `CLAUDE.md`: Branch-Strategie an die gelebte Praxis anpassen + +**Warum:** Commit `f6c4a21` vom 04.07.2026 behebt bereits Rate-Limiting, Token-Hashing, +die Mock-Route und die Secret-Datei — wurde aber nie gemerged. `dev` fehlen inzwischen +**70 Commits** von `main`, ein Merge würde alte Dateistände zurückholen. Deshalb: +Commit als *Vorlage* nehmen, Änderungen neu anwenden, `dev` danach entsorgen. + +`CLAUDE.md` beschreibt einen `dev` → `main`-Workflow, der faktisch nicht gelebt wird +(alles läuft direkt auf `main`). Doku an die Realität anpassen ist ehrlicher. + +--- + +## Block B — Sicherheit + +### B1. Rate-Limiting auf den Auth-Endpunkten (Audit 4) +- [x] Sliding-Window-Limiter in `src/lib/rate-limit.ts` anlegen +- [x] Einbinden in `signup` +- [x] Einbinden in `resend-verification` +- [x] Einbinden in `reset-password` +- [x] Einbinden in `verify-email` +- [x] Limits dokumentieren (Vorschlag: 5 Versuche / 15 Minuten je IP) + +**Warum:** Im gesamten `src/` ist **kein Limiter** vorhanden. Signup, Passwort-Reset und +Verification-Mails sind unbegrenzt aufrufbar — Einladung zum Missbrauch deines SMTP-Zugangs. + +### B2. Passwort-Reset-Token nur als SHA-256-Hash speichern (Audit 4) +- [x] Beim Erzeugen: Klartext per Mail, **Hash** in die DB +- [x] Beim Einlösen: eingehenden Token hashen, dann vergleichen +- [ ] Migration für bestehende Tokens (einfachster Weg: alle invalidieren) + +**Warum:** `src/app/api/auth/reset-password/route.ts:28` legt `passwordResetToken` im +**Klartext** ab. Wer Lesezugriff auf die Datenbank hat, kann fremde Konten übernehmen. +Der Token selbst kommt aus `crypto.randomUUID()` — das ist in Ordnung, nur die Ablage nicht. + +### B3. Secrets aus dem Git entfernen und rotieren (Audit 2) +- [ ] `.env.production.save` aus dem Arbeitsbaum löschen +- [ ] `.gitignore` auf `.env*` erweitern (mit Ausnahme für `.env.example`) +- [ ] `NEXTAUTH_SECRET` neu erzeugen +- [ ] `SMTP_PASSWORD` neu setzen +- [ ] Datenbank-Passwort rotieren +- [ ] Entscheiden: Historie umschreiben oder belassen +- [ ] Nach dem Wechsel: einmal neu einloggen + +**Warum:** `.env.production.save` ist eingecheckt und enthält **echte Werte** — +`DATABASE_URL` (70 Zeichen), `NEXTAUTH_SECRET` (20 Zeichen), `SMTP_PASSWORD` (20 Zeichen). +`.gitignore` listet `.env.production`, die `.save`-Endung fällt durchs Raster. +Drin seit Commit `da8d019`. + +**Entwarnung:** Das GitHub-Repo ist **privat** — nicht öffentlich abgreifbar. Die Werte +stehen aber in der kompletten Historie, in jedem Klon und auf Gitea. + +Anleitung siehe [`docs/secret-rotation.md`](docs/secret-rotation.md). + +--- + +## Block C — Build und Typsicherheit + +### C1. Build darf Fehler nicht mehr verschlucken (Audit 1) +- [ ] **Schritt 1:** `eslint.ignoreDuringBuilds` in `next.config.js` auf `false` +- [ ] Gemeldete ESLint-Fehler abarbeiten +- [ ] **Schritt 2:** `npm run type-check` laufen lassen, Fehlerzahl notieren +- [ ] Fehler abarbeiten — modulweise, nicht auf einmal +- [ ] **Schritt 3:** `typescript.ignoreBuildErrors` auf `false` +- [ ] `any`-Stellen reduzieren (aktuell **452**) — fortlaufend, kein Blocker + +**Warum:** `next.config.js` hat `ignoreDuringBuilds: true` **und** `ignoreBuildErrors: true`. +`tsconfig.json` sagt zwar `strict: true`, das ist aber wirkungslos, weil der Build die Fehler +gar nicht meldet. **Ein Typfehler fällt nicht beim Bauen auf, sondern erst als kaputte Seite +im Browser.** Ein erfolgreicher Build ist derzeit *kein* Beweis, dass der Code korrekt ist. + +**Achtung Reihenfolge:** Erst ESLint, dann TypeScript. Beides gleichzeitig abzuschalten +blockiert jeden Deploy, bis alles behoben ist. + +--- + +## Block D — Struktur + +### D1. Dateien über 4000 Zeilen aufteilen (Audit 7) + +Betroffen sind genau zwei Dateien: + +| Datei | Zeilen | +|---|---:| +| `src/components/WeeklyView.tsx` | 11.906 | +| `src/components/SettingsSidebar.tsx` | 5.445 | + +**Voraussetzung:** C1 muss laufen — ohne Typecheck ist ein Refactoring dieser Größe +Blindflug. Ebenso hilfreich: A2, damit es überhaupt Tests gibt. + +#### WeeklyView.tsx → Zielstruktur +- [ ] `src/components/weekly/` anlegen +- [ ] Reine Helfer nach `src/lib/weekly/` (Datum, Slots, Formatierung) +- [ ] Drag & Drop in einen Hook `useTaskDragDrop.ts` +- [ ] Someday-Listen in `SomedayLists.tsx` +- [ ] All-Day-Leiste in `AllDayBar.tsx` +- [ ] Zeitraster + Tagesspalten in `TimeGrid.tsx` / `DayColumn.tsx` +- [ ] Einstellungs-Flyouts in `QuickSettingsPanel.tsx` +- [ ] Daten-Laden in `useWeeklyData.ts` +- [ ] `WeeklyView.tsx` bleibt als Orchestrator übrig + +#### SettingsSidebar.tsx → Zielstruktur +- [ ] `src/components/settings/` anlegen +- [ ] Ein Panel pro Themenbereich als eigene Datei +- [ ] Gemeinsame Bausteine (Farbwähler, Schalterzeile) in `settings/controls/` + +**Vorgehen:** In kleinen Schritten, jeder Schritt ein eigener Commit, nach jedem Schritt +Typecheck. Kein "großer Wurf" — die Datei ist zu zentral. + +**Warum:** `WeeklyView.tsx` ist ein Viertel des gesamten Quellcodes. Der Weekday-Bug von +Anfang September saß genau dort und war nur deshalb schwer zu finden. + +--- + +## Block E — Dauerhafte Absicherung + +### E1. Regelmäßige automatische Prüfung (Audit 8) +- [x] SonarQube-Analyse gegen https://sq.martin-bierschenk.de einrichten +- [x] Regelmäßigen Lauf aufsetzen — nightly 03:00, `todo-sonar.timer` (systemd user unit) +- [x] `npm audit` in denselben Lauf aufnehmen (`scripts/quality-check.sh`) +- [ ] `todo-sonar.timer` auf `scripts/quality-check.sh` umstellen statt nur `todo-sonar-scan` +- [ ] Playwright-Tests in den nächtlichen Lauf aufnehmen (braucht laufende App — erst nach A2) +- [ ] Quality Gate im SonarQube definieren +- [ ] Ergebnis irgendwohin melden, wo es auffällt (bisher nur Konsolenausgabe) +- [ ] **Entscheiden:** `.github/workflows/sonarqube.yml` löschen oder behalten + +**Stand:** Die Analyse läuft bereits — nächtlich um 03:00 auf dem Spark, per systemd-Timer, +Ergebnis auf https://sq.martin-bierschenk.de. Was fehlte, war `npm audit` (Sicherheitslücken +in Abhängigkeiten findet SonarQube nicht) und ein Bündel-Skript. Beides ist jetzt da. + +**Zur Entscheidung:** GitHub Actions wurde am 02.09. bewusst verworfen — das Repo ist privat, +Actions-Minuten kosten, und der SonarQube-Server läuft ohnehin selbst gehostet. Trotzdem liegt +seit dem 03.09. ein Workflow im Repo, der bei jedem Push auf `main` anspringt. Er ruft +`npm run type-check` und `npm run lint` auf — beides schlägt derzeit fehl (siehe C1), der +Workflow wäre also dauerhaft rot. Sein Projekt-Key war zudem falsch und hätte in ein zweites, +leeres SonarQube-Projekt geschrieben; das ist korrigiert. Entweder löschen (passt zur +Entscheidung vom 02.09.) oder bewusst behalten und C1 vorziehen. + +**Warum:** Bisher fallen Regressionen erst in Produktion auf. In Kombination mit C1 +(Build meldet Fehler) und A2 (Tests laufen) entsteht daraus ein echtes Netz. + +### E2. Abhängigkeiten auf Sicherheitslücken prüfen +- [ ] `npm audit` auf dem Container ausführen +- [ ] Kritische Funde beheben + +**Warum:** Konnte im Audit **nicht** geprüft werden — `node_modules` ist auf dem +CIFS-Share nicht installierbar (keine Symlinks). Zu bekannten Lücken in den +Abhängigkeiten liegt daher **keine Aussage** vor. Muss nachgeholt werden. + +--- + +## Kleinkram (ohne Eile) + +- [ ] 114 `console.log` aus dem Produktionscode entfernen (die 275 `console.error` sind in Ordnung) +- [ ] `NEXTAUTH_SECRET` auf 32 Byte verlängern (erledigt sich mit B3) +- [ ] API-Routen: JSON-401 statt HTML-Redirect für nicht angemeldete Aufrufe +- [ ] `deploy.sh` und `docker-compose.yml` löschen oder als veraltet markieren — sie + beschreiben ein Portainer-Setup, das nicht mehr verwendet wird +- [ ] Eigener Auth-Check in `apple/sync` und `weather/geocode` (aktuell nur durch die + Middleware geschützt — zusätzliche Absicherung schadet nicht) diff --git a/docs/secret-rotation.md b/docs/secret-rotation.md new file mode 100644 index 0000000..29c1556 --- /dev/null +++ b/docs/secret-rotation.md @@ -0,0 +1,196 @@ +# Secrets rotieren + +Anleitung zum Austausch der Zugangsdaten, die versehentlich in `.env.production.save` +im Git gelandet sind. + +**Grundregel:** Kein Befehl in dieser Anleitung gibt einen Geheimwert auf dem Bildschirm +aus. Die neuen Werte werden direkt in die Datei geschrieben. Du siehst sie nie, ich sehe +sie nie, und sie landen nicht in der Shell-History (dort steht nur der Befehl, nicht sein +Ergebnis). + +--- + +## Vorab: Was passiert mit den Daten? + +Kurz: **Es geht nichts verloren.** Im Einzelnen: + +| Was du änderst | Folge für die Daten | Folge für die Nutzer | +|---|---|---| +| `NEXTAUTH_SECRET` | keine | Alle werden ausgeloggt, Login danach normal möglich | +| Datenbank-Passwort | keine | keine | +| `SMTP_PASSWORD` | keine | keine | + +Wichtig zu verstehen: **Die Passwörter der Nutzer hängen nicht am `NEXTAUTH_SECRET`.** +Sie liegen als bcrypt-Hashes in der Datenbank und bleiben davon völlig unberührt. +`NEXTAUTH_SECRET` signiert nur die Sitzungs-Cookies. Wird es getauscht, sind die +ausgestellten Cookies ungültig — jeder muss sich **einmal neu anmelden**, mit dem +gewohnten Passwort. Danach ist alles wie vorher. + +Beim Datenbank-Passwort änderst du nur die *Zugangsdaten*, nicht die Datenbank. +Tabellen und Inhalte bleiben unangetastet. + +--- + +## Schritt 0 — Sicherheitsnetz + +Auf dem Container: + +```bash +cd /root/My-Weekly-ToDo-List +cp .env .env.backup-$(date +%F) +chmod 600 .env.backup-* +ls -l .env.backup-* +``` + +Falls etwas schiefgeht, kommst du damit zurück. Die Sicherung liegt außerhalb von Git +(`.env*` ist ignoriert) — bitte trotzdem später aufräumen. + +Zusätzlich ein Datenbank-Backup, bevor du am DB-Zugang drehst: + +```bash +bash /root/My-Weekly-ToDo-List/scripts/backup-db.sh +``` + +--- + +## Schritt 1 — `NEXTAUTH_SECRET` neu erzeugen + +Ein Befehl, der den neuen Wert erzeugt und **direkt** in die Datei schreibt: + +```bash +cd /root/My-Weekly-ToDo-List +sed -i "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=\"$(openssl rand -base64 32)\"|" .env +``` + +Kontrolle, ohne den Wert zu zeigen — es wird nur die Länge ausgegeben: + +```bash +awk -F= '/^NEXTAUTH_SECRET=/{v=substr($0,index($0,"=")+1); gsub(/"/,"",v); print "Länge:", length(v), "Zeichen"}' .env +``` + +Erwartet: **44 Zeichen** (32 Byte in Base64). Der alte Wert hatte 20 — also deutlich zu kurz. + +--- + +## Schritt 2 — Datenbank-Passwort rotieren + +Das passiert bei Supabase, nicht auf dem Server: + +1. Supabase-Projekt öffnen → **Settings** → **Database** +2. **Reset database password** — neues Passwort erzeugen lassen +3. Passwort in deinen Passwort-Manager, **nicht** in eine Datei kopieren +4. Supabase zeigt dir die fertigen Connection-Strings für `DATABASE_URL` (Pooler, Port 6543) + und `DIRECT_URL` (direkt, Port 5432) + +Auf dem Container einsetzen — dieser Weg zeigt die Eingabe nicht an: + +```bash +cd /root/My-Weekly-ToDo-List + +read -rsp "Neue DATABASE_URL einfügen: " V && printf '\n' && \ + sed -i "s|^DATABASE_URL=.*|DATABASE_URL=\"$V\"|" .env && unset V + +read -rsp "Neue DIRECT_URL einfügen: " V && printf '\n' && \ + sed -i "s|^DIRECT_URL=.*|DIRECT_URL=\"$V\"|" .env && unset V +``` + +`read -rs` unterdrückt die Anzeige beim Tippen, `unset` räumt die Variable weg. + +Verbindung prüfen, ohne Zugangsdaten auszugeben: + +```bash +npx prisma db execute --stdin <<< "SELECT 1;" && echo "Datenbank erreichbar" +``` + +--- + +## Schritt 3 — SMTP-Passwort rotieren + +1. Beim Mail-Anbieter ein neues Passwort setzen (idealerweise ein App-Passwort) +2. Auf dem Container eintragen: + +```bash +cd /root/My-Weekly-ToDo-List +read -rsp "Neues SMTP-Passwort: " V && printf '\n' && \ + sed -i "s|^SMTP_PASSWORD=.*|SMTP_PASSWORD=\"$V\"|" .env && unset V +``` + +--- + +## Schritt 4 — Übernehmen + +```bash +cd /root/My-Weekly-ToDo-List +cp .env .next/standalone/.env +pm2 restart my-weekly-todo --update-env +pm2 save +``` + +Der Kopierschritt ist nötig, weil der Standalone-Build seine eigene `.env` neben +`server.js` liest. + +Danach: einmal im Browser neu anmelden. Dass du ausgeloggt wurdest, ist der **Beweis**, +dass das neue `NEXTAUTH_SECRET` greift. + +--- + +## Schritt 5 — Die Datei aus dem Git entfernen + +```bash +cd /root/My-Weekly-ToDo-List # oder im Arbeitsrepo +git rm --cached .env.production.save +rm .env.production.save +``` + +`.gitignore` ergänzen: + +``` +.env* +!.env.example +``` + +Committen, pushen, fertig. + +--- + +## Schritt 6 — Historie: umschreiben oder nicht? + +Nach Schritt 5 ist die Datei aus dem *aktuellen* Stand verschwunden — in der **Historie** +steht sie weiterhin. + +**Meine Empfehlung: Historie so lassen.** Begründung: + +- Das Repository ist **privat** (geprüft). +- Nach den Schritten 1–3 sind die enthaltenen Werte **wertlos**, weil überall ersetzt. +- Historie umschreiben ändert jeden Commit-Hash. Alle Klone (dein Rechner, der Container, + Gitea) müssten neu geholt werden, alte Tags zeigen ins Leere. Das Risiko übersteigt + den Nutzen deutlich. + +Rotieren schlägt Umschreiben: Ein rotiertes Geheimnis in der Historie ist harmlos, ein +nicht rotiertes bleibt auch nach dem Umschreiben in jedem existierenden Klon. + +Falls du es trotzdem willst — nur bei Bedarf, mit vorherigem Voll-Backup: + +```bash +git filter-repo --path .env.production.save --invert-paths +``` + +Danach `git push --force` auf **beide** Remotes, und jeder Klon muss neu erstellt werden. + +--- + +## Kontrolle zum Schluss + +```bash +cd /root/My-Weekly-ToDo-List +git ls-files | grep -E "^\.env" || echo "OK: keine .env-Datei mehr im Git" +grep -cE "^(NEXTAUTH_SECRET|DATABASE_URL|DIRECT_URL|SMTP_PASSWORD)=" .env +``` + +Die zweite Zeile muss `4` ergeben — vier gesetzte Schlüssel, ohne einen davon zu zeigen. + +Zum Schluss die Sicherungen aufräumen: + +```bash +shred -u .env.backup-* 2>/dev/null || rm -f .env.backup-* +``` diff --git a/package-lock.json b/package-lock.json index 479fc59..3887164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "my-weekly-todo-list", - "version": "1.116.4", + "version": "1.117.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "my-weekly-todo-list", - "version": "1.116.4", + "version": "1.117.0", "license": "MIT", "dependencies": { "@auth/prisma-adapter": "^2.11.1", diff --git a/package.json b/package.json index 5c68180..5ac8420 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.116.4", + "version": "1.117.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { @@ -9,7 +9,9 @@ "start": "echo \"\\n ✦ My Weekly ToDo List v$(node -p \"require('./package.json').version\") — production\\n\" && next start", "lint": "next lint", "type-check": "tsc --noEmit", - "test": "jest", + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:debug": "playwright test tests/debug", "sonar": "todo-sonar-scan" }, "keywords": [ @@ -60,17 +62,14 @@ }, "devDependencies": { "@playwright/test": "^1.58.0", - "@types/jest": "^30.0.0", "@types/node": "^20.0.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "autoprefixer": "^10.4.19", "eslint": "^8.0.0", "eslint-config-next": "^14.0.0", - "jest": "^29.0.0", "postcss": "^8.4.38", "tailwindcss": "^3.4.1", - "ts-jest": "^29.0.0", "typescript": "^5.0.0" } } diff --git a/playwright.config.ts b/playwright.config.ts index 0288d35..30f32b6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -13,6 +13,8 @@ import { defineConfig, devices } from '@playwright/test'; */ export default defineConfig({ testDir: './tests', + /* tests/debug enthält Wegwerf-Skripte aus der Fehlersuche, keine Regressionstests */ + testIgnore: '**/debug/**', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ diff --git a/scripts/quality-check.sh b/scripts/quality-check.sh new file mode 100644 index 0000000..60cee99 --- /dev/null +++ b/scripts/quality-check.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# +# Wiederkehrende Qualitäts- und Sicherheitsprüfung. +# +# Gedacht für den nächtlichen Lauf auf dem Spark (todo-sonar.timer), kann aber jederzeit +# von Hand gestartet werden: bash scripts/quality-check.sh +# +# Bewusst NICHT auf dem Produktionscontainer: LXC 202 hat 2 Kerne und bedient Live-Verkehr, +# und ein Scan zur Deploy-Zeit findet Probleme erst, nachdem sie ausgeliefert wurden. + +main() { + set -uo pipefail # kein -e: jede Prüfung soll laufen, auch wenn eine vorher meckert + + local FAILED=0 + step() { echo; echo "=== $1 ==="; } + + cd "$(dirname "$0")/.." + + step "Abhängigkeiten auf bekannte Sicherheitslücken" + npm audit --audit-level=high || FAILED=1 + + step "TypeScript" + npm run type-check || FAILED=1 + + step "ESLint" + npm run lint || FAILED=1 + + step "SonarQube" + if command -v todo-sonar-scan >/dev/null; then + todo-sonar-scan || FAILED=1 + else + echo "todo-sonar-scan nicht gefunden — übersprungen" + fi + + echo + if [ "$FAILED" -eq 0 ]; then + echo "=== Alle Prüfungen bestanden ===" + else + echo "=== Mindestens eine Prüfung hat angeschlagen (siehe oben) ===" + fi + return "$FAILED" +} + +main "$@" diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts deleted file mode 100644 index 5f7f8a5..0000000 --- a/src/app/api/auth/login/route.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -export const dynamic = 'force-dynamic'; -import { compare } from 'bcryptjs'; - -export async function POST(request: NextRequest) { - try { - const { email, password } = await request.json(); - - // Basic validation - if (!email || !password) { - return NextResponse.json( - { error: 'Email and password are required' }, - { status: 400 } - ); - } - - // In a real app, you would fetch user from database here - // For now, we'll simulate a user lookup - console.log('Attempting to login user with email:', email); - - // Simulate database lookup - const mockStoredPassword = '$2b$10$examplehashedpassword'; // This would come from DB - - // Compare passwords - const isValid = await compare(password, mockStoredPassword); - - if (!isValid) { - return NextResponse.json( - { error: 'Invalid email or password' }, - { status: 401 } - ); - } - - // Create a mock JWT token (in a real app, you would use a proper JWT library) - const token = `mock-jwt-token-${Date.now()}`; - - // Set cookie - const response = NextResponse.json({ message: 'Login successful' }); - response.cookies.set('auth_token', token, { - httpOnly: true, - maxAge: 15 * 60, // 15 minutes - path: '/', - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict' - }); - - return response; - } catch (error) { - console.error('Login error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/src/app/api/auth/resend-verification/route.ts b/src/app/api/auth/resend-verification/route.ts index fad455e..450202e 100644 --- a/src/app/api/auth/resend-verification/route.ts +++ b/src/app/api/auth/resend-verification/route.ts @@ -1,10 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; export const dynamic = 'force-dynamic'; import { prisma } from '@/lib/prisma'; +import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit'; import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email'; export async function POST(request: NextRequest) { try { + // Mail-Versand drosseln: ohne Limit ist der Endpunkt unbegrenzt aufrufbar + const limit = rateLimit(`resend-verification:${clientIp(request)}`, 3, 15 * 60 * 1000); + if (!limit.ok) return tooManyRequests(limit.retryAfter); + const { email } = await request.json(); if (!email) { diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts index 435068e..d419f33 100644 --- a/src/app/api/auth/reset-password/route.ts +++ b/src/app/api/auth/reset-password/route.ts @@ -3,10 +3,14 @@ export const dynamic = 'force-dynamic'; import { hash } from 'bcryptjs'; import { prisma } from '@/lib/prisma'; import { sendPasswordResetEmail } from '@/lib/email'; -import crypto from 'crypto'; +import { createToken, hashToken } from '@/lib/tokens'; +import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit'; export async function POST(request: NextRequest) { try { + const limit = rateLimit(`reset-password:${clientIp(request)}`, 5, 15 * 60 * 1000); + if (!limit.ok) return tooManyRequests(limit.retryAfter); + const body = await request.json(); const { email, token, newPassword } = body; @@ -19,13 +23,15 @@ export async function POST(request: NextRequest) { return NextResponse.json({ message: 'If an account exists, a reset link has been sent.' }); } - const resetToken = crypto.randomUUID(); + const resetToken = createToken(); const resetExpires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour await prisma.user.update({ where: { email }, data: { - passwordResetToken: resetToken, + // Only the hash is stored: read access to the database must not yield a + // working reset link. + passwordResetToken: hashToken(resetToken), passwordResetExpires: resetExpires, }, }); @@ -46,7 +52,7 @@ export async function POST(request: NextRequest) { const user = await (prisma.user as any).findFirst({ where: { - passwordResetToken: token, + passwordResetToken: hashToken(token), passwordResetExpires: { gt: new Date() }, }, }); @@ -58,7 +64,7 @@ export async function POST(request: NextRequest) { ); } - const hashedPassword = await hash(newPassword, 10); + const hashedPassword = await hash(newPassword, 12); await prisma.user.update({ where: { id: user.id }, diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index c1c4ccd..51bebee 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -1,12 +1,17 @@ import { NextRequest, NextResponse } from 'next/server'; export const dynamic = 'force-dynamic'; import { prisma } from '@/lib/prisma'; +import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit'; import { hash } from 'bcryptjs'; import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email'; import { languageFromAcceptLanguage } from '@/lib/emailTemplates'; export async function POST(request: NextRequest) { try { + // Kontoerstellung drosseln: ohne Limit ist der Endpunkt unbegrenzt aufrufbar + const limit = rateLimit(`signup:${clientIp(request)}`, 5, 15 * 60 * 1000); + if (!limit.ok) return tooManyRequests(limit.retryAfter); + const body = await request.json(); const { name, email, password } = body; // Prefer the explicit language from the form (filled in by the signup diff --git a/src/app/api/auth/verify-email/route.ts b/src/app/api/auth/verify-email/route.ts index 8fc5613..945c06b 100644 --- a/src/app/api/auth/verify-email/route.ts +++ b/src/app/api/auth/verify-email/route.ts @@ -1,10 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; export const dynamic = 'force-dynamic'; import { prisma } from '@/lib/prisma'; +import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit'; // POST: Verify via 6-digit code export async function POST(request: NextRequest) { try { + // Code-Raten drosseln: ohne Limit ist der Endpunkt unbegrenzt aufrufbar + const limit = rateLimit(`verify-email:${clientIp(request)}`, 10, 15 * 60 * 1000); + if (!limit.ok) return tooManyRequests(limit.retryAfter); + const { email, code } = await request.json(); if (!email || !code) { diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..0879ded --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,106 @@ +/** + * Sliding-window rate limiter, in-memory. + * + * The app runs as a single PM2 fork process, so a process-local Map is enough. If it ever + * runs clustered or on more than one host, this needs to move to Redis or the database — + * each process would otherwise keep its own counter and the effective limit would multiply. + * + * Entries are pruned on write, and the map is capped, so a flood of unique keys cannot grow + * it without bound. + */ + +type Hit = number[]; // timestamps (ms) inside the current window + +const buckets = new Map(); + +/** Above this many distinct keys the oldest are dropped — a crude but effective memory bound. */ +const MAX_KEYS = 10_000; + +/** Prune at most this often, so a burst does not trigger a sweep per request. */ +const PRUNE_INTERVAL_MS = 60_000; +let lastPrune = 0; + +function prune(now: number, windowMs: number): void { + if (now - lastPrune < PRUNE_INTERVAL_MS) return; + lastPrune = now; + + for (const [key, hits] of buckets) { + const alive = hits.filter((t) => now - t < windowMs); + if (alive.length === 0) buckets.delete(key); + else buckets.set(key, alive); + } + + // Still too many keys: drop the oldest entries (Map preserves insertion order). + if (buckets.size > MAX_KEYS) { + const excess = buckets.size - MAX_KEYS; + let i = 0; + for (const key of buckets.keys()) { + if (i++ >= excess) break; + buckets.delete(key); + } + } +} + +export interface RateLimitResult { + /** false when the caller has exhausted its allowance */ + ok: boolean; + /** requests left in the current window */ + remaining: number; + /** seconds until the window frees up again (0 when ok) */ + retryAfter: number; +} + +/** + * Records one hit for `key` and reports whether it is still within the allowance. + * + * @param key what to count per — usually `${route}:${ip}` + * @param limit allowed requests per window + * @param windowMs window length in milliseconds + */ +export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult { + const now = Date.now(); + prune(now, windowMs); + + const hits = (buckets.get(key) ?? []).filter((t) => now - t < windowMs); + + if (hits.length >= limit) { + const oldest = hits[0]; + buckets.set(key, hits); + return { + ok: false, + remaining: 0, + retryAfter: Math.max(1, Math.ceil((windowMs - (now - oldest)) / 1000)), + }; + } + + hits.push(now); + buckets.set(key, hits); + return { ok: true, remaining: limit - hits.length, retryAfter: 0 }; +} + +/** + * Best-effort client IP. + * + * Behind the reverse proxy the socket address is always the proxy's, so the forwarded + * headers are the only usable signal. They are spoofable by anyone talking to the app + * directly — acceptable for throttling, never use this for authorisation. + */ +export function clientIp(request: Request): string { + const forwarded = request.headers.get("x-forwarded-for"); + if (forwarded) return forwarded.split(",")[0].trim(); + return request.headers.get("x-real-ip") ?? "unknown"; +} + +/** 429 response with the standard Retry-After header. */ +export function tooManyRequests(retryAfter: number): Response { + return new Response( + JSON.stringify({ error: "Too many requests. Please try again later." }), + { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": String(retryAfter), + }, + }, + ); +} diff --git a/src/lib/tokens.ts b/src/lib/tokens.ts new file mode 100644 index 0000000..0364074 --- /dev/null +++ b/src/lib/tokens.ts @@ -0,0 +1,23 @@ +import { createHash, randomBytes } from "crypto"; + +/** + * One-time tokens for password reset and e-mail verification. + * + * The token is sent to the user in plain text and stored only as a SHA-256 hash. Anyone with + * read access to the database therefore holds something unusable: they cannot reverse the + * hash into a working reset link. + * + * SHA-256 without a salt is deliberate here — unlike a password, the token is 256 bits of + * CSPRNG output, so there is nothing to brute-force or look up in a rainbow table. Using + * bcrypt would only make every verification needlessly slow. + */ + +/** A fresh, unguessable token. Give this to the user, never store it as-is. */ +export function createToken(): string { + return randomBytes(32).toString("hex"); +} + +/** The value to store and to compare against. */ +export function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} diff --git a/tests/auth-flow-test.spec.ts b/tests/debug/auth-flow-test.spec.ts similarity index 100% rename from tests/auth-flow-test.spec.ts rename to tests/debug/auth-flow-test.spec.ts diff --git a/tests/basic-playwright-test.spec.ts b/tests/debug/basic-playwright-test.spec.ts similarity index 100% rename from tests/basic-playwright-test.spec.ts rename to tests/debug/basic-playwright-test.spec.ts diff --git a/tests/css-debug-test.spec.ts b/tests/debug/css-debug-test.spec.ts similarity index 100% rename from tests/css-debug-test.spec.ts rename to tests/debug/css-debug-test.spec.ts diff --git a/tests/db-connection-test.spec.ts b/tests/debug/db-connection-test.spec.ts similarity index 100% rename from tests/db-connection-test.spec.ts rename to tests/debug/db-connection-test.spec.ts diff --git a/tests/external-db-test.spec.ts b/tests/debug/external-db-test.spec.ts similarity index 100% rename from tests/external-db-test.spec.ts rename to tests/debug/external-db-test.spec.ts diff --git a/tests/local-deployment-test.spec.ts b/tests/debug/local-deployment-test.spec.ts similarity index 100% rename from tests/local-deployment-test.spec.ts rename to tests/debug/local-deployment-test.spec.ts diff --git a/tests/login-basic-debug.spec.ts b/tests/debug/login-basic-debug.spec.ts similarity index 100% rename from tests/login-basic-debug.spec.ts rename to tests/debug/login-basic-debug.spec.ts diff --git a/tests/login-debug-simplified.spec.ts b/tests/debug/login-debug-simplified.spec.ts similarity index 100% rename from tests/login-debug-simplified.spec.ts rename to tests/debug/login-debug-simplified.spec.ts diff --git a/tests/login-debug.spec.ts b/tests/debug/login-debug.spec.ts similarity index 100% rename from tests/login-debug.spec.ts rename to tests/debug/login-debug.spec.ts diff --git a/tests/login-simple-debug.spec.ts b/tests/debug/login-simple-debug.spec.ts similarity index 100% rename from tests/login-simple-debug.spec.ts rename to tests/debug/login-simple-debug.spec.ts diff --git a/tests/port-9323-test.spec.ts b/tests/debug/port-9323-test.spec.ts similarity index 100% rename from tests/port-9323-test.spec.ts rename to tests/debug/port-9323-test.spec.ts diff --git a/tests/simple-connect-test.spec.ts b/tests/debug/simple-connect-test.spec.ts similarity index 100% rename from tests/simple-connect-test.spec.ts rename to tests/debug/simple-connect-test.spec.ts