2 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5 a122187feb feat(auth): Durchgang durch Anmeldung und Sitzungen nach OWASP, v1.8.0
CI / pruefen (push) Canceled after 0s
Geprueft gegen ASVS und die Cheat Sheets zu Authentication, Session
Management und Password Storage. Das meiste stand schon gut da – Token mit
256 Bit und nur als Hash in der Datenbank, serverseitige Pruefung bei jeder
Anfrage, Sitzungen weg beim Passwortwechsel, TOTP mit Replay-Schutz. Fuenf
Punkte blieben.

**Aufwand beim Hashen.** N war 2^15 bei p=1. Das Password Storage Cheat
Sheet nennt 2^17/p=1 als Minimum und listet als gleichwertig unter anderem
2^16/p=2 und 2^15/p=3 – mit p=1 lag 2^15 unter *allen* zulaessigen
Kombinationen und war rund dreimal billiger zu knacken als vorgesehen. Jetzt
2^17/p=1, gemessen 0,5 s und 128 MiB je Pruefung. Der Speicher ist gedeckelt:
Node rechnet scrypt im libuv-Threadpool, also hoechstens vier parallel.

**Neuhashen bei der Anmeldung.** Ohne das blieben bestehende Passwoerter fuer
immer auf den alten Parametern – anheben laesst er sich nur dort, wo das
Klartextpasswort vorliegt. needsRehash vergleicht den Gesamtaufwand N*r*p,
nicht Feld fuer Feld, damit gleichwertige Kombinationen nicht grundlos neu
gehasht werden.

**Aufzaehlbarkeit.** Drei Zweige beantworteten sich *vor* der Passwort-
pruefung und verrieten damit die Existenz eines Kontos: gesperrt (423),
deaktiviert (403) und "kein Passwort hinterlegt, nimm einen Passkey" (401).
Der einheitliche Text GENERIC war damit wirkungslos. Jetzt wird zuerst das
Passwort geprueft; Klartext gibt es erst danach. Reine Passkey-Konten
verbrennen dieselbe Rechenzeit, damit sich der Fall auch nicht an der Dauer
zeigt. Der Preis ist der verlorene Hinweis "nimm den Passkey" – bewusst
hingenommen.

**Leerlauffrist.** lastSeenAt wurde gefuehrt, aber nie ausgewertet: ein
abgegriffener Cookie galt bis zu 30 Tage, egal ob das Konto benutzt wurde.
ASVS 3.3.2 will beide Grenzen. Acht Stunden, ueber SESSION_IDLE_HOURS
einstellbar. Das Schreibintervall fuer lastSeenAt haengt jetzt an der Frist –
bei fester Stunde haette eine kuerzere Frist sich selbst ausgehebelt.

**Cookies.** SameSite Strict statt Lax, und bei https der Praefix "__Host-".
Der laesst den Browser erzwingen, dass kein Domain-Attribut gesetzt ist –
eine andere Subdomain kann den Cookie dann nicht ueberschreiben. Beides
kostet einmalig eine neue Anmeldung.

Dazu die Passwortpruefung: Wiederholungen, durchlaufende Zeichenfolgen und
Passwoerter, die den Benutzernamen enthalten, werden abgelehnt.
Zusammensetzungsregeln bleiben bewusst aus – ASVS 2.1.9 raet davon ab.

Geprueft: 232 Unit-Tests (davon 12 neue zu Aufwand, Neuhashen und Mustern)
und 30 E2E-Tests, darunter einer, der die Antwort auf ein stillgelegtes
Konto gegen die auf ein erfundenes haelt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:38:05 +02:00
florian.wildeandClaude Opus 5 1fe41ac2a5 fix(ui): Projektseite passt wieder in die Fensterbreite
CI / pruefen (push) Canceled after 0s
Auf dem Telefon war die Projektseite doppelt so breit wie der Bildschirm:
die Kopfleiste endete auf halber Strecke, "Jetzt synchronisieren" und der
Notiz-Knopf lagen ausserhalb.

Ursache sind die beiden Rasterspalten. Ein Rasterkind hat "min-width: auto"
und weigert sich damit, unter die Inhaltsbreite seines breitesten Kindes zu
schrumpfen. Breitestes Kind ist die Heatmap mit ueber 700px – sie zog die
Spalte auf und mit ihr die ganze Seite. Das overflow-x-auto um sie herum
kam gar nicht zum Zug, weil der Elter ihm die volle Breite zugestand.

Mit min-w-0 an beiden Spalten greift es wieder: gemessen auf 390px liegen
scrollWidth und clientWidth jetzt gleichauf, vorher 811 zu 390.

Warum das beim Umbau der mobilen Ansicht nicht auffiel: die Messung lief
gegen ein Projekt ohne Repository, ohne Snapshot und ohne README. Ohne
Heatmap gibt es nichts, was die Spalte aufziehen koennte. Nachgestellt
wurde das jetzt mit einem vollstaendigen Projekt – Snapshot, README mit
Codeblock und Tabelle, Notizen, Aufgaben.

Ein Regressionstest dafuer fehlt noch: die E2E-Tests legen Projekte ueber
die API an und kommen ohne echten Abgleich zu keinem Snapshot, also auch
zu keiner Heatmap. Ein Test ohne sie wuerde die Sicherheit vortaeuschen,
ohne den Fall zu treffen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 07:32:04 +02:00
19 changed files with 449 additions and 50 deletions
+6 -1
View File
@@ -69,9 +69,14 @@ ADMINER_PORT="127.0.0.1:8080"
# und Passkeys sind davon nicht betroffen. # und Passkeys sind davon nicht betroffen.
APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert" APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert"
# Lebensdauer einer Anmeldung in Tagen. # Absolute Lebensdauer einer Anmeldung in Tagen – ab dem Anmelden gerechnet
# und nicht verlaengerbar.
SESSION_TTL_DAYS="30" SESSION_TTL_DAYS="30"
# Leerlauffrist in Stunden: so lange darf eine Sitzung unbenutzt bleiben.
# Wird bei jeder Nutzung neu angestossen. 0 schaltet die Pruefung ab.
SESSION_IDLE_HOURS="8"
# ── Benachrichtigungen ─────────────────────────────────────── # ── Benachrichtigungen ───────────────────────────────────────
# ntfy und Webhook brauchen nichts weiter – die Ziele werden in der # ntfy und Webhook brauchen nichts weiter – die Ziele werden in der
# Anwendung unter "Mein Konto" eingerichtet. # Anwendung unter "Mein Konto" eingerichtet.
+23 -2
View File
@@ -22,8 +22,19 @@ sonst würden Konten ausgesperrt. Nach der Einrichtung ist `/setup` dauerhaft ge
### 0.2 Anmeldung ### 0.2 Anmeldung
- **Benutzername und Passwort.** Mindestens 10 Zeichen, offensichtliche Passwörter werden - **Benutzername und Passwort.** Mindestens 10 Zeichen. Abgelehnt werden naheliegende
abgelehnt. Hashing mit scrypt. Passwörter, reine Ziffernfolgen, zu wenig verschiedene Zeichen (`abababab`),
durchlaufende Folgen (`abcdefgh`) und alles, was den eigenen Benutzernamen enthält.
**Keine Zusammensetzungsregeln** — ASVS 2.1.9 rät ausdrücklich davon ab, weil sie zu
`Passwort1!` führen statt zu Entropie.
- **Hashing mit scrypt**, `N = 2^17, r = 8, p = 1` nach dem OWASP Password Storage Cheat
Sheet (rund 0,5 s und 128 MiB je Prüfung). Die Parameter stehen im Hash, ältere bleiben
also lesbar — und werden bei der nächsten Anmeldung still auf den aktuellen Stand
gehoben. Das ist der einzige Moment, in dem das Klartextpasswort vorliegt.
- **Gleiche Antwort für alles Fehlgeschlagene.** Ob ein Konto existiert, gesperrt,
deaktiviert oder reines Passkey-Konto ist, sagt die Anmeldung erst, wenn das Passwort
stimmt. Davor gibt es genau einen Text und eine vergleichbare Antwortzeit
(`fakeVerifyDelay`), damit sich Benutzernamen nicht durchprobieren lassen.
- **Passkey (WebAuthn).** Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel. Die - **Passkey (WebAuthn).** Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel. Die
Anmeldung läuft über auffindbare Credentials, ein Benutzername ist also nicht nötig. Anmeldung läuft über auffindbare Credentials, ein Benutzername ist also nicht nötig.
Mehrere Passkeys pro Konto, benennbar und einzeln entfernbar. Mehrere Passkeys pro Konto, benennbar und einzeln entfernbar.
@@ -760,6 +771,16 @@ nicht verloren gehen. Indizes auf Status, Verbindung und Änderungsdatum.
`GIT_BLOCK_PRIVATE` und `GIT_ALLOWED_HOSTS`. `GIT_BLOCK_PRIVATE` und `GIT_ALLOWED_HOSTS`.
- **Rate-Limiting** für Anmeldung (je IP und je Konto), ausgehende Git-Aufrufe und - **Rate-Limiting** für Anmeldung (je IP und je Konto), ausgehende Git-Aufrufe und
WebAuthn-Optionen. WebAuthn-Optionen.
- **Sitzungen**: Zufallstoken mit 256 Bit, in der Datenbank nur als SHA-256-Hash — wer die
Datenbank liest, kann keine Sitzung übernehmen. Geprüft wird bei jeder Anfrage, Abmelden
und Sperren wirken also sofort. Zwei Fristen nach ASVS 3.3.2: **absolut** 30 Tage ab
Anmeldung (`SESSION_TTL_DAYS`) und **im Leerlauf** 8 Stunden (`SESSION_IDLE_HOURS`).
Ein Passwortwechsel entwertet alle Sitzungen und stellt für das eigene Gerät eine neue
aus.
- **Cookies**: `HttpOnly`, `SameSite=Strict`, `Secure` sobald die App über HTTPS läuft, und
dann zusätzlich mit dem Präfix `__Host-`. Der Präfix lässt den Browser selbst erzwingen,
dass kein `Domain` gesetzt ist — eine andere Subdomain kann den Cookie damit nicht
überschreiben und keine Sitzung unterschieben.
- Access Tokens verschlüsselt mit **AES-256-GCM**, Schlüssel per **scrypt** aus - Access Tokens verschlüsselt mit **AES-256-GCM**, Schlüssel per **scrypt** aus
`APP_SECRET` abgeleitet, Chiffrat versioniert (`v1.<iv>.<tag>.<data>`). `APP_SECRET` abgeleitet, Chiffrat versioniert (`v1.<iv>.<tag>.<data>`).
- Tokens werden nie an den Browser gesendet — alle API-Antworten nutzen eine Feldauswahl - Tokens werden nie an den Browser gesendet — alle API-Antworten nutzen eine Feldauswahl
+2 -1
View File
@@ -118,7 +118,8 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
| `APP_URL` | Öffentliche Adresse — bestimmt die WebAuthn-RP-ID | `http://localhost:3000` | | `APP_URL` | Öffentliche Adresse — bestimmt die WebAuthn-RP-ID | `http://localhost:3000` |
| `DATABASE_URL` | PostgreSQL-Verbindung (Prisma) | `postgresql://nebula:nebula@localhost:5432/nebula` | | `DATABASE_URL` | PostgreSQL-Verbindung (Prisma) | `postgresql://nebula:nebula@localhost:5432/nebula` |
| `APP_SECRET` | Schlüssel für die Token-Verschlüsselung (AES-256-GCM) | — **unbedingt setzen** | | `APP_SECRET` | Schlüssel für die Token-Verschlüsselung (AES-256-GCM) | — **unbedingt setzen** |
| `SESSION_TTL_DAYS` | Lebensdauer einer Anmeldung in Tagen | `30` | | `SESSION_TTL_DAYS` | Absolute Lebensdauer einer Anmeldung in Tagen | `30` |
| `SESSION_IDLE_HOURS` | Nach so vielen Stunden ohne Nutzung läuft die Sitzung ab (`0` = aus) | `8` |
| `PORT` | Port der Anwendung | `3000` | | `PORT` | Port der Anwendung | `3000` |
| `APP_PORT` | Host-Port-Mapping des App-Containers | `127.0.0.1:3000` | | `APP_PORT` | Host-Port-Mapping des App-Containers | `127.0.0.1:3000` |
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` | | `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
+46
View File
@@ -279,6 +279,52 @@ test("Mehrere Repositories je Projekt", async ({ request, playwright }) => {
expect(zumSchluss.repositories[0].repoName).toBe("swr"); expect(zumSchluss.repositories[0].repoName).toBe("swr");
}); });
test("Die Anmeldung verraet nicht, welche Konten es gibt", async ({ request, playwright }) => {
await login(request, ADMIN);
// Ein Konto anlegen und stilllegen.
const angelegt = await request.post("/api/admin/users", {
data: {
username: "stillgelegt",
password: "ein-ausreichend-langes-passwort",
role: "USER",
},
});
expect(angelegt.status()).toBe(201);
const id = (await angelegt.json()).user.id;
expect((await request.patch(`/api/admin/users/${id}`, { data: { isActive: false } })).ok())
.toBeTruthy();
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
// Falsches Passwort auf dem stillgelegten Konto ...
const stillgelegt = await anonym.post("/api/auth/login", {
data: { username: "stillgelegt", password: "falsches-passwort-hier" },
});
// ... und auf einem Konto, das es gar nicht gibt.
const erfunden = await anonym.post("/api/auth/login", {
data: { username: "gibtesnicht", password: "falsches-passwort-hier" },
});
// Beides muss ununterscheidbar sein – sonst laesst sich durchprobieren,
// welche Benutzernamen vergeben sind. Frueher kam hier 403 gegen 401.
expect(stillgelegt.status()).toBe(erfunden.status());
expect(stillgelegt.status()).toBe(401);
expect(await stillgelegt.json()).toEqual(await erfunden.json());
// Mit dem richtigen Passwort darf die Anwendung dann Klartext reden.
const mitPasswort = await anonym.post("/api/auth/login", {
data: { username: "stillgelegt", password: "ein-ausreichend-langes-passwort" },
});
expect(mitPasswort.status()).toBe(403);
expect((await mitPasswort.json()).error).toMatch(/deaktiviert/);
await anonym.dispose();
await request.delete(`/api/admin/users/${id}`);
});
test("Webhook: Signatur entscheidet, sonst passiert nichts", async ({ request, playwright }) => { test("Webhook: Signatur entscheidet, sonst passiert nichts", async ({ request, playwright }) => {
await login(request, ADMIN); await login(request, ADMIN);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.7.0", "version": "1.8.0",
"private": true, "private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": { "scripts": {
+1 -1
View File
@@ -47,7 +47,7 @@ export async function POST(request: Request) {
} }
} }
const weak = validatePasswordStrength(parsed.data.newPassword); const weak = validatePasswordStrength(parsed.data.newPassword, user.username);
if (weak) { if (weak) {
return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 }); return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 });
} }
+1 -1
View File
@@ -69,7 +69,7 @@ export async function PATCH(request: Request, { params }: Params) {
} }
if (password) { if (password) {
const weak = validatePasswordStrength(password); const weak = validatePasswordStrength(password, target.username);
if (weak) { if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 }); return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
} }
+1 -1
View File
@@ -47,7 +47,7 @@ export async function POST(request: Request) {
const { username, displayName, email, password, role } = parsed.data; const { username, displayName, email, password, role } = parsed.data;
const weak = validatePasswordStrength(password); const weak = validatePasswordStrength(password, username);
if (weak) { if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 }); return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
} }
+36 -14
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa"; import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa";
import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password"; import { fakeVerifyDelay, hashPassword, needsRehash, verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session"; import { createSession, setSessionCookie } from "@/lib/auth/session";
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users"; import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -42,6 +42,30 @@ export async function POST(request: Request) {
return jsonError(GENERIC, 401); return jsonError(GENERIC, 401);
} }
/**
* Erst das Passwort pruefen, dann ueber den Kontozustand reden.
*
* Frueher standen die Abfragen auf gesperrt, deaktiviert und
* "kein Passwort hinterlegt" **vor** der Pruefung und beantworteten sich
* damit ohne jede Kenntnis des Passworts. Wer einen Benutzernamen riet,
* bekam an der Antwort gesagt, ob es ihn gibt – genau die Aufzaehlbarkeit,
* die der einheitliche Text GENERIC sonst verhindert.
*
* Jetzt gilt: falsches Passwort → immer dieselbe Antwort. Die
* hilfreichere Auskunft bekommt nur, wer das Passwort kennt.
*/
const valid = user.passwordHash
? await verifyPassword(password, user.passwordHash)
: // Kein Passwort hinterlegt (reines Passkey-Konto): trotzdem Zeit
// verbrennen, damit sich der Fall nicht an der Antwortdauer zeigt.
await fakeVerifyDelay().then(() => false);
if (!valid) {
// Fehlversuche nur zaehlen, wo es ein Passwort zu erraten gibt.
if (user.passwordHash) await noteFailedLogin(user.id);
return jsonError(GENERIC, 401);
}
const locked = lockRemainingSeconds(user.lockedUntil); const locked = lockRemainingSeconds(user.lockedUntil);
if (locked > 0) { if (locked > 0) {
return jsonError( return jsonError(
@@ -51,22 +75,20 @@ export async function POST(request: Request) {
} }
if (!user.isActive) { if (!user.isActive) {
await fakeVerifyDelay();
return jsonError("Dieses Konto ist deaktiviert.", 403); return jsonError("Dieses Konto ist deaktiviert.", 403);
} }
if (!user.passwordHash) { /**
await fakeVerifyDelay(); * Der Aufwand beim Hashen ist gestiegen. Wer sich mit einem Passwort
return jsonError( * anmeldet, dessen Hash noch nach den alten Vorgaben erzeugt wurde,
"Fuer dieses Konto ist kein Passwort hinterlegt – bitte mit Passkey anmelden.", * bekommt hier still einen neuen – es ist der einzige Moment, in dem das
401, * Klartextpasswort vorliegt. Nebenlaeufig, damit die Anmeldung nicht auf
); * die halbe Sekunde wartet.
} */
if (needsRehash(user.passwordHash)) {
const valid = await verifyPassword(password, user.passwordHash); void hashPassword(password)
if (!valid) { .then((neu) => prisma.user.update({ where: { id: user.id }, data: { passwordHash: neu } }))
await noteFailedLogin(user.id); .catch(() => undefined);
return jsonError(GENERIC, 401);
} }
// Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier // Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier
+1 -1
View File
@@ -39,7 +39,7 @@ export async function POST(request: Request) {
const { username, displayName, email, password } = parsed.data; const { username, displayName, email, password } = parsed.data;
const weak = validatePasswordStrength(password); const weak = validatePasswordStrength(password, username);
if (weak) { if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 }); return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
} }
+1 -1
View File
@@ -34,7 +34,7 @@ export async function POST(request: Request) {
const { username, displayName, email, password, mode } = parsed.data; const { username, displayName, email, password, mode } = parsed.data;
const weak = validatePasswordStrength(password); const weak = validatePasswordStrength(password, username);
if (weak) { if (weak) {
return NextResponse.json( return NextResponse.json(
{ error: weak, fields: { password: weak } }, { error: weak, fields: { password: weak } },
+10 -2
View File
@@ -85,8 +85,16 @@ export default async function ProjectPage({ params }: PageProps) {
progressFromTasks={detail.progressFromTasks} progressFromTasks={detail.progressFromTasks}
/> />
{/*
min-w-0 an beiden Spalten ist hier nicht Kosmetik, sondern der Kern:
ein Rasterkind hat "min-width: auto" und weigert sich damit, unter
die Inhaltsbreite seines breitesten Kindes zu schrumpfen. Die
Heatmap ist ueber 700px breit – auf dem Telefon zog sie damit die
ganze Spalte und mit ihr die Seite auseinander. Erst mit min-w-0
greift das overflow-x-auto darin ueberhaupt.
*/}
<div className="grid gap-6 lg:grid-cols-12"> <div className="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-5"> <div className="min-w-0 space-y-6 lg:col-span-5">
<section className="glass p-5"> <section className="glass p-5">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300"> <h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<FileText className="h-4 w-4 text-cyan-300" /> <FileText className="h-4 w-4 text-cyan-300" />
@@ -115,7 +123,7 @@ export default async function ProjectPage({ params }: PageProps) {
<NotesPanel projectId={detail.id} notes={detail.notes} /> <NotesPanel projectId={detail.id} notes={detail.notes} />
</div> </div>
<div className="space-y-6 lg:col-span-7"> <div className="min-w-0 space-y-6 lg:col-span-7">
<GitPanel <GitPanel
project={detail} project={detail}
connections={connections as unknown as ConnectionSafe[]} connections={connections as unknown as ConnectionSafe[]}
+2 -2
View File
@@ -46,7 +46,7 @@ export function setMfaCookie(response: NextResponse, token: string) {
name: MFA_COOKIE, name: MFA_COOKIE,
value: token, value: token,
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "strict",
secure: COOKIES_SECURE, secure: COOKIES_SECURE,
path: "/", path: "/",
maxAge: Math.floor(MFA_TTL_MS / 1000), maxAge: Math.floor(MFA_TTL_MS / 1000),
@@ -58,7 +58,7 @@ export function clearMfaCookie(response: NextResponse) {
name: MFA_COOKIE, name: MFA_COOKIE,
value: "", value: "",
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "strict",
secure: COOKIES_SECURE, secure: COOKIES_SECURE,
path: "/", path: "/",
maxAge: 0, maxAge: 0,
+80 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PASSWORD_MIN_LENGTH } from "./constants"; import { PASSWORD_MIN_LENGTH } from "./constants";
import { hashPassword, validatePasswordStrength, verifyPassword } from "./password"; import { hashPassword, needsRehash, validatePasswordStrength, verifyPassword } from "./password";
describe("hashPassword und verifyPassword", () => { describe("hashPassword und verifyPassword", () => {
it("erkennt das richtige Passwort wieder", async () => { it("erkennt das richtige Passwort wieder", async () => {
@@ -64,3 +64,82 @@ describe("validatePasswordStrength", () => {
expect(validatePasswordStrength("a".repeat(300))).toMatch(/hoechstens/); expect(validatePasswordStrength("a".repeat(300))).toMatch(/hoechstens/);
}); });
}); });
describe("Aufwand nach OWASP", () => {
it("hasht mit den heute vorgesehenen Parametern", async () => {
// OWASP Password Storage Cheat Sheet: N = 2^17, r = 8, p = 1.
const hash = await hashPassword("ein-langes-testpasswort");
const [, N, r, p] = hash.split("$");
expect(Number(N)).toBe(131072);
expect(Number(r)).toBe(8);
expect(Number(p)).toBe(1);
});
it("prueft alte Hashes weiterhin, weil die Parameter im Hash stehen", async () => {
// Von Hand mit den frueheren Werten erzeugt – muss gueltig bleiben,
// sonst waere ein Anheben des Aufwands eine Aussperrung.
const alt =
"scrypt$32768$8$1$" +
Buffer.from("salzsalzsalzsalz").toString("base64url") +
"$";
// Echten Hash mit alten Parametern bauen:
const { scrypt } = await import("node:crypto");
const { promisify } = await import("node:util");
const s = promisify(scrypt) as (a: string, b: Buffer, c: number, d: object) => Promise<Buffer>;
const salt = Buffer.from("salzsalzsalzsalz");
const derived = await s("ein-langes-testpasswort", salt, 64, {
N: 32768,
r: 8,
p: 1,
maxmem: 96 * 1024 * 1024,
});
const alterHash = `scrypt$32768$8$1$${salt.toString("base64url")}$${derived.toString("base64url")}`;
expect(alt.length).toBeGreaterThan(0);
await expect(verifyPassword("ein-langes-testpasswort", alterHash)).resolves.toBe(true);
// ... und wird als erneuerungsbeduerftig erkannt.
expect(needsRehash(alterHash)).toBe(true);
});
it("laesst frische Hashes in Ruhe", async () => {
expect(needsRehash(await hashPassword("ein-langes-testpasswort"))).toBe(false);
});
it("erkennt gleichwertige Kombinationen als ausreichend", () => {
// N=2^16 mit p=2 ergibt denselben Gesamtaufwand wie N=2^17 mit p=1 und
// ist nach OWASP ebenso zulaessig.
expect(needsRehash("scrypt$65536$8$2$c2FsdA$aGFzaA")).toBe(false);
// N=2^15 mit p=1 dagegen nicht – das war der alte Zustand.
expect(needsRehash("scrypt$32768$8$1$c2FsdA$aGFzaA")).toBe(true);
});
it("haelt Unsinn fuer erneuerungsbeduerftig", () => {
expect(needsRehash("voelliger-unsinn")).toBe(true);
expect(needsRehash(null)).toBe(false);
});
});
describe("Schwache Muster", () => {
it("weist Wiederholungen ab", () => {
expect(validatePasswordStrength("aaaaaaaaaaaa")).toBeTruthy();
expect(validatePasswordStrength("abababababab")).toBeTruthy();
});
it("weist Zeichenfolgen ab", () => {
expect(validatePasswordStrength("abcdefghijkl")).toBeTruthy();
expect(validatePasswordStrength("87654321abcd")).toBeTruthy();
});
it("weist Passwoerter mit dem Benutzernamen ab", () => {
expect(validatePasswordStrength("chefin-geheimnis", "chefin")).toMatch(/Benutzernamen/);
// Ohne Benutzernamen ist dasselbe Passwort in Ordnung.
expect(validatePasswordStrength("chefin-geheimnis")).toBeNull();
// Sehr kurze Namen zaehlen nicht – sonst scheitert fast alles.
expect(validatePasswordStrength("ab-langes-passwort", "ab")).toBeNull();
});
it("laesst ein gutes Passwort mit Wiederholung durch", () => {
// Ein "ll" ist kein Grund zur Ablehnung, solange genug Vielfalt da ist.
expect(validatePasswordStrength("Rollladen-Fenster-42")).toBeNull();
});
});
+122 -10
View File
@@ -17,11 +17,24 @@ const scrypt = promisify(scryptCallback) as (
options: { N: number; r: number; p: number; maxmem: number }, options: { N: number; r: number; p: number; maxmem: number },
) => Promise<Buffer>; ) => Promise<Buffer>;
const PARAMS = { N: 32768, r: 8, p: 1 }; /**
* Arbeitsaufwand nach der Empfehlung des OWASP Password Storage Cheat Sheet:
* N = 2^17, r = 8, p = 1. Frueher stand hier N = 2^15 mit p = 1 – das liegt
* unter *jeder* der dort genannten zulaessigen Kombinationen (zu 2^15
* gehoerte p = 3) und war damit rund dreimal billiger zu knacken als
* vorgesehen.
*
* Gemessen kostet das rund 0,5 s je Pruefung und 128 MiB. Der Speicher
* klingt nach viel, ist aber gedeckelt: Node rechnet scrypt im
* libuv-Threadpool, standardmaessig vier Faeden parallel. Mehr als rund
* 512 MiB koennen also nicht gleichzeitig belegt sein, und davor liegen
* noch die Ratenbegrenzungen je IP und je Benutzername.
*/
const PARAMS = { N: 131072, r: 8, p: 1 };
const KEY_LENGTH = 64; const KEY_LENGTH = 64;
// scrypt braucht rund 128 * N * r Bytes; der Standardwert von 32 MB reicht // scrypt braucht rund 128 * N * r Bytes – bei diesen Werten 128 MiB.
// dafuer nicht ganz. // Etwas Luft nach oben, damit die Pruefung nicht am Deckel scheitert.
const MAX_MEM = 96 * 1024 * 1024; const MAX_MEM = 192 * 1024 * 1024;
export async function hashPassword(password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16); const salt = randomBytes(16);
@@ -72,6 +85,30 @@ export async function verifyPassword(
return timingSafeEqual(derived, expected); return timingSafeEqual(derived, expected);
} }
/**
* Ist der gespeicherte Hash mit schwaecheren Parametern erzeugt worden als
* heute ueblich?
*
* Der Aufwand laesst sich nur fuer neue Passwoerter anheben – bestehende
* Hashes bleiben sonst fuer immer auf dem alten Stand. OWASP empfiehlt
* deshalb, bei der naechsten erfolgreichen Anmeldung still neu zu hashen.
* Das geht nur dort, weil das Klartextpasswort ausschliesslich in diesem
* Moment vorliegt.
*/
export function needsRehash(stored: string | null | undefined): boolean {
if (!stored) return false;
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return true;
const [N, r, p] = [Number(parts[1]), Number(parts[2]), Number(parts[3])];
if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) return true;
// Verglichen wird der Gesamtaufwand, nicht Feld fuer Feld: N=2^16 mit p=2
// ist nach OWASP genauso zulaessig wie N=2^17 mit p=1.
return N * r * p < PARAMS.N * PARAMS.r * PARAMS.p;
}
/** /**
* Verbrennt vergleichbar viel Zeit wie eine echte Pruefung. Wird aufgerufen, * Verbrennt vergleichbar viel Zeit wie eine echte Pruefung. Wird aufgerufen,
* wenn es den Benutzernamen gar nicht gibt – sonst liesse sich an der * wenn es den Benutzernamen gar nicht gibt – sonst liesse sich an der
@@ -84,27 +121,102 @@ export async function fakeVerifyDelay(): Promise<void> {
}); });
} }
/**
* Naheliegende Passwoerter.
*
* ASVS 2.1.7 verlangt einen Abgleich gegen bekannte geleakte Passwoerter.
* Ein Dienst wie Have-I-Been-Pwned schiede dafuer aus – Nebula ruft
* grundsaetzlich nichts von aussen, und ein Passwort-Praefix nach draussen
* zu geben widerspraeche dem erst recht. Diese Liste ist der Kompromiss:
* die haeufigsten deutschen und englischen Kandidaten, dazu weiter unten
* Muster statt Einzelwoerter, was mehr abdeckt als jede Liste dieser Groesse.
*/
const OBVIOUS = new Set([ const OBVIOUS = new Set([
"password", "passwort", "12345678", "123456789", "1234567890", // Klassiker
"qwertzuiop", "qwertyuiop", "administrator", "changeme", "letmein", "password", "password1", "password123", "passwort", "passwort1",
"willkommen", "nebula123", "geheim123", "passwort123", "kennwort", "geheim", "geheim123", "changeme", "letmein",
"welcome", "willkommen", "willkommen1", "administrator", "admin1234",
"iloveyou", "sunshine", "princess", "dragon", "monkey", "football",
"baseball", "superman", "trustno1", "starwars", "whatever", "freedom",
// Zahlen und Tastaturlaeufe
"12345678", "123456789", "1234567890", "87654321", "11111111",
"qwertzuiop", "qwertyuiop", "asdfghjkl", "qwertz123", "qwerty123",
"1qay2wsx", "1q2w3e4r", "1q2w3e4r5t", "asdf1234", "qwer1234",
// Deutsch
"sommer2024", "sommer2025", "sommer2026", "winter2024", "winter2025",
"winter2026", "fussball", "deutschland", "hallowelt", "sonnenschein",
"schatz123", "liebling", "blumen123", "internet", "computer",
// Projektnah
"nebula", "nebula123", "nebula2026", "dashboard", "vibecoding",
]); ]);
export { PASSWORD_MIN_LENGTH }; export { PASSWORD_MIN_LENGTH };
/** Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt. */ /**
export function validatePasswordStrength(password: string): string | null { * Laenge der laengsten auf- oder absteigenden Zeichenfolge ("abcd", "4321").
*
* Zurueckgegeben wird die Laenge und nicht nur ein Ja/Nein: entscheidend ist,
* *wie viel* des Passworts aus der Folge besteht. Ein "de" mitten in einem
* Wort ist harmlos, "abcdefghijkl" ist das ganze Passwort.
*/
function laengsteFolge(wert: string): number {
if (wert.length < 2) return wert.length;
let beste = 1;
let auf = 1;
let ab = 1;
for (let i = 1; i < wert.length; i += 1) {
const schritt = wert.charCodeAt(i) - wert.charCodeAt(i - 1);
auf = schritt === 1 ? auf + 1 : 1;
ab = schritt === -1 ? ab + 1 : 1;
beste = Math.max(beste, auf, ab);
}
return beste;
}
/**
* Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt.
*
* Bewusst **keine** Zusammensetzungsregeln ("mindestens ein Sonderzeichen").
* ASVS 2.1.9 raet ausdruecklich davon ab: sie treiben Leute zu "Passwort1!"
* und senken die Entropie, statt sie zu heben. Geprueft wird stattdessen
* auf Laenge und auf das, was nachweislich schwach ist.
*/
export function validatePasswordStrength(
password: string,
/** Benutzername des Kontos, falls bekannt – er darf nicht im Passwort stehen. */
username?: string | null,
): string | null {
if (password.length < PASSWORD_MIN_LENGTH) { if (password.length < PASSWORD_MIN_LENGTH) {
return `Das Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen haben.`; return `Das Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen haben.`;
} }
if (password.length > 200) { if (password.length > 200) {
return "Das Passwort darf hoechstens 200 Zeichen haben."; return "Das Passwort darf hoechstens 200 Zeichen haben.";
} }
if (OBVIOUS.has(password.toLowerCase())) {
const klein = password.toLowerCase();
if (OBVIOUS.has(klein)) {
return "Dieses Passwort ist zu naheliegend."; return "Dieses Passwort ist zu naheliegend.";
} }
if (/^\d+$/.test(password)) { if (/^\d+$/.test(password)) {
return "Nur Ziffern reichen nicht – bitte Buchstaben oder Zeichen ergaenzen."; return "Nur Ziffern reichen nicht – bitte Buchstaben oder Zeichen ergaenzen.";
} }
// Zu wenig verschiedene Zeichen – deckt "aaaaaaaa" ebenso ab wie
// "abababab" oder "12121212", ohne dafuer je ein eigenes Muster zu
// brauchen.
if (new Set(klein).size <= 4) {
return "Das Passwort benutzt zu wenige verschiedene Zeichen.";
}
// Und wenn der groesste Teil des Passworts eine durchlaufende Folge ist.
if (laengsteFolge(klein) >= Math.max(6, Math.ceil(password.length * 0.6))) {
return "Zeichenfolgen wie „abcd“ oder „4321“ sind zu leicht zu raten.";
}
// Ein Passwort, das den Benutzernamen enthaelt, ist der erste Versuch
// jedes Angreifers.
const name = username?.trim().toLowerCase();
if (name && name.length >= 3 && klein.includes(name)) {
return "Das Passwort darf den Benutzernamen nicht enthalten.";
}
return null; return null;
} }
+33 -6
View File
@@ -2,7 +2,12 @@ import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import type { NextResponse } from "next/server"; import type { NextResponse } from "next/server";
import type { User } from "@prisma/client"; import type { User } from "@prisma/client";
import { COOKIES_SECURE, SESSION_COOKIE, SESSION_TTL_MS } from "@/lib/config"; import {
COOKIES_SECURE,
SESSION_COOKIE,
SESSION_IDLE_MS,
SESSION_TTL_MS,
} from "@/lib/config";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
/** /**
@@ -68,7 +73,10 @@ export function setSessionCookie(response: NextResponse, token: string, expiresA
name: SESSION_COOKIE, name: SESSION_COOKIE,
value: token, value: token,
httpOnly: true, httpOnly: true,
sameSite: "lax", // Strict statt Lax: Nebula hat keinen Einstieg von aussen, den eine
// fremde Seite anstossen duerfte. Der Preis ist, dass ein Klick von
// einer anderen Seite auf die Anmeldung fuehrt – ein Neuladen genuegt.
sameSite: "strict",
secure: COOKIES_SECURE, secure: COOKIES_SECURE,
path: "/", path: "/",
expires: expiresAt, expires: expiresAt,
@@ -80,7 +88,10 @@ export function clearSessionCookie(response: NextResponse) {
name: SESSION_COOKIE, name: SESSION_COOKIE,
value: "", value: "",
httpOnly: true, httpOnly: true,
sameSite: "lax", // Strict statt Lax: Nebula hat keinen Einstieg von aussen, den eine
// fremde Seite anstossen duerfte. Der Preis ist, dass ein Klick von
// einer anderen Seite auf die Anmeldung fuehrt – ein Neuladen genuegt.
sameSite: "strict",
secure: COOKIES_SECURE, secure: COOKIES_SECURE,
path: "/", path: "/",
maxAge: 0, maxAge: 0,
@@ -109,11 +120,27 @@ export async function getSessionUser(): Promise<SessionUser | null> {
return null; return null;
} }
// Leerlaufgrenze. Vorher wurde lastSeenAt zwar gefuehrt, aber nie
// ausgewertet – ein abgegriffener Cookie galt damit bis zum absoluten
// Ablauf, auch wenn das Konto wochenlang unberuehrt war.
const untaetig = Date.now() - session.lastSeenAt.getTime();
if (SESSION_IDLE_MS > 0 && untaetig > SESSION_IDLE_MS) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
return null;
}
if (!session.user.isActive) return null; if (!session.user.isActive) return null;
// Hoechstens einmal pro Stunde schreiben, damit nicht jede Anfrage die /**
// Datenbank anfasst. * lastSeenAt nachfuehren, aber nicht bei jeder Anfrage schreiben.
if (Date.now() - session.lastSeenAt.getTime() > 60 * 60 * 1000) { *
* Das Intervall muss deutlich unter der Leerlaufgrenze liegen, sonst
* verkuerzt die Schreibbremse sie: bei stuendlichem Schreiben und einer
* Grenze von einer Stunde waere die Sitzung schon nach Sekunden faellig.
* Ein Zwanzigstel der Frist, hoechstens fuenf Minuten.
*/
const intervall = SESSION_IDLE_MS > 0 ? Math.min(SESSION_IDLE_MS / 20, 5 * 60 * 1000) : 60 * 60 * 1000;
if (untaetig > intervall) {
void prisma.session void prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } }) .update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => undefined); .catch(() => undefined);
+39
View File
@@ -41,6 +41,45 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
}; };
export const CHANGELOG: Release[] = [ export const CHANGELOG: Release[] = [
{
version: "1.8.0",
date: "2026-08-15",
headline: "Durchgang durch Anmeldung und Sitzungen nach OWASP.",
changes: [
{
kind: "besser",
text:
"Passwörter werden deutlich aufwendiger gehasht (scrypt mit N=2^17 statt 2^15) — " +
"das entspricht der Empfehlung des OWASP Password Storage Cheat Sheet. Bestehende " +
"Passwörter werden bei der nächsten Anmeldung still auf den neuen Stand gebracht.",
},
{
kind: "behoben",
text:
"Die Anmeldung verriet, welche Benutzernamen existieren: gesperrte, deaktivierte " +
"und reine Passkey-Konten antworteten anders als unbekannte — und das schon vor " +
"der Passwortprüfung. Jetzt gibt es die Auskunft erst, wenn das Passwort stimmt.",
},
{
kind: "neu",
text:
"Sitzungen laufen nach acht Stunden ohne Nutzung ab, zusätzlich zur bestehenden " +
"Frist von 30 Tagen. Einstellbar über SESSION_IDLE_HOURS; 0 schaltet es ab.",
},
{
kind: "besser",
text:
"Das Sitzungscookie trägt bei HTTPS den Präfix „__Host-“ und gilt nur noch " +
"SameSite=Strict. Beides muss einmalig neu angemeldet werden.",
},
{
kind: "besser",
text:
"Die Passwortprüfung erkennt jetzt Wiederholungen, Zeichenfolgen und Passwörter, " +
"die den eigenen Benutzernamen enthalten.",
},
],
},
{ {
version: "1.7.0", version: "1.7.0",
date: "2026-08-15", date: "2026-08-15",
+32 -3
View File
@@ -73,18 +73,47 @@ export const WEBAUTHN_AVAILABLE =
/** Cookies nur ueber TLS ausliefern, wenn die App per https laeuft. */ /** Cookies nur ueber TLS ausliefern, wenn die App per https laeuft. */
export const COOKIES_SECURE = APP_URL_PARSED.protocol === "https:"; export const COOKIES_SECURE = APP_URL_PARSED.protocol === "https:";
/** Lebensdauer einer Sitzung. */ /**
* Absolute Lebensdauer einer Sitzung – ab Anmeldung, nicht verlaengerbar.
* Nach dieser Frist ist Schluss, egal wie aktiv jemand war.
*/
export const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS ?? 30); export const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS ?? 30);
export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000; export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
export const SESSION_COOKIE = "nebula_session"; /**
* Leerlauffrist: so lange darf eine Sitzung unbenutzt bleiben.
*
* ASVS 3.3.2 verlangt beides – eine absolute *und* eine Leerlaufgrenze.
* Vorher gab es nur die absolute, ein abgegriffener Cookie galt also bis zu
* 30 Tage, unabhaengig davon, ob das Konto benutzt wurde.
*
* Acht Stunden sind der Kompromiss: ein Arbeitstag am Stueck bleibt
* angemeldet, ueber Nacht laeuft die Sitzung ab. Wem das zu streng ist,
* setzt SESSION_IDLE_HOURS hoch; 0 schaltet die Leerlaufgrenze ab.
*/
export const SESSION_IDLE_HOURS = Number(process.env.SESSION_IDLE_HOURS ?? 8);
export const SESSION_IDLE_MS =
SESSION_IDLE_HOURS > 0 ? SESSION_IDLE_HOURS * 60 * 60 * 1000 : 0;
/**
* Name des Sitzungscookies.
*
* Mit dem Praefix "__Host-" erzwingt der Browser selbst, was wir ohnehin
* setzen: Secure, Pfad "/" und kein Domain-Attribut. Letzteres ist der
* eigentliche Gewinn – eine andere Subdomain kann den Cookie dann nicht
* ueberschreiben und damit keine Sitzung unterschieben.
*
* Nur bei https, denn der Praefix verlangt zwingend Secure; ohne TLS wuerde
* der Browser den Cookie schlicht verwerfen.
*/
export const SESSION_COOKIE = COOKIES_SECURE ? "__Host-nebula_session" : "nebula_session";
/** /**
* Cookie fuer die halbfertige Anmeldung zwischen Passwort und zweitem Faktor. * Cookie fuer die halbfertige Anmeldung zwischen Passwort und zweitem Faktor.
* Kurzlebig – wer den Code nicht binnen fuenf Minuten eingibt, faengt von * Kurzlebig – wer den Code nicht binnen fuenf Minuten eingibt, faengt von
* vorne an. * vorne an.
*/ */
export const MFA_COOKIE = "nebula_mfa"; export const MFA_COOKIE = COOKIES_SECURE ? "__Host-nebula_mfa" : "nebula_mfa";
export const MFA_TTL_MS = 5 * 60 * 1000; export const MFA_TTL_MS = 5 * 60 * 1000;
/** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */ /** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */
+12 -2
View File
@@ -15,7 +15,17 @@ import { NextResponse, type NextRequest } from "next/server";
*/ */
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const SESSION_COOKIE = "nebula_session";
/**
* Beide moeglichen Namen des Sitzungscookies.
*
* Bei https traegt er den Praefix "__Host-", sonst nicht (der Praefix
* verlangt zwingend Secure). Welcher gilt, entscheidet lib/config anhand
* der APP_URL – hier im Edge-Runtime wird schlicht auf beide geschaut. Es
* geht ohnehin nur um die grobe Vorpruefung; verbindlich entscheidet
* requireUserPage bzw. requireUserApi.
*/
const SESSION_COOKIES = ["__Host-nebula_session", "nebula_session"];
/** Ohne Anmeldung erreichbar. */ /** Ohne Anmeldung erreichbar. */
const PUBLIC_PATHS = ["/login", "/setup", "/register"]; const PUBLIC_PATHS = ["/login", "/setup", "/register"];
@@ -110,7 +120,7 @@ export function middleware(request: NextRequest) {
} }
// ── 2. Zugangsschranke ───────────────────────────────────── // ── 2. Zugangsschranke ─────────────────────────────────────
const hasSession = Boolean(request.cookies.get(SESSION_COOKIE)?.value); const hasSession = SESSION_COOKIES.some((name) => Boolean(request.cookies.get(name)?.value));
if (!hasSession && !isPublicPath(pathname)) { if (!hasSession && !isPublicPath(pathname)) {
if (pathname.startsWith("/api/")) { if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });