13 KiB
Security- und Plausibilitäts-Review: Kundenportal & Authentifizierung
Hier ist das detaillierte Sicherheits-, Architektur- und Plausibilitäts-Review für das Kundenportal von webkulisse. Das Review analysiert den Ist-Zustand (Authentifizierung auf Basis von Better-Auth und TanStack Start / React Router) und liefert konkrete Handlungsempfehlungen, damit künftige Module (Rechnungsverwaltung und Produktabnahmen) modular, mandantensicher und ohne Spaghetti-Code integriert werden können.
1. Priorisierte Findings & Sicherheitsrisiken
🔴 KRITISCH: Fehlendes Mandanten- & Autorisierungskonzept für Multi-Kunden-Daten (IDOR-Gefahr)
-
Dateiverweis:
src/db/schema.ts,src/lib/auth.ts,src/lib/session.ts -
Problem & Risiko: Das aktuelle Schema speichert lediglich flache
user-Einträge mit einer einfachen Rolle (role: 'user' | 'admin'). Es gibt keine Mandanten- bzw. Kundenentität (Organization,CustomeroderTenant).Wenn Rechnungen und Abnahmen direkt auf
userIdreferenzieren:- Kann ein Kunde nicht mehrere Mitarbeiter/Ansprechpartner haben (typischer B2B-Anwendungsfall: Inhaber + Buchhaltung).
- Besteht ohne standardisierte Mandanten-Middleware bei kommenden Server-Funktionen ein extremes Risiko für IDOR (Insecure Direct Object Reference) – z. B. wenn ein Kunde über manipulierte IDs Rechnungen oder Abnahmen anderer Kunden abrufen oder freigeben kann.
-
Lösungsempfehlung: Einführung einer expliziten
customer/organization-Tabelle und einer typsicheren Server-Function-Middleware, die bei jedem Datenzugriff den aktuellen Mandantenkontext prüft.
// src/db/schema.ts - Empfohlenes Schema-Fundament
export const customer = pgTable('customer', {
id: text('id').primaryKey(),
name: text('name').notNull(), // Firmenname z. B. 'Praxis Dr. Schmidt'
customerNumber: text('customer_number').notNull().unique(), // z. B. KD-2026-001
status: text('status').notNull().default('active'),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
export const user = pgTable('user', {
// ... bestehende Better-Auth Felder
customerId: text('customer_id').references(() => customer.id, { onDelete: 'cascade' }),
})
🔴 KRITISCH: Devtools und sensible Debugger-Panels im Produktions-Bundle
- Dateiverweis:
src/routes/__root.tsx(Zeilen 94–104),vite.config.ts(Zeile 14) - Problem & Risiko:
<TanStackDevtools />und<TanStackRouterDevtoolsPanel />werden imRootDocumentbedingungslos gerendert. Dadurch werden Router-Zustände, Context-Daten und interne Routing-Strukturen im Browser für jeden Besucher sichtbar. - Lösungsempfehlung: Devtools nur in der Entwicklungsumgebung laden und im Build strippen:
// src/routes/__root.tsx
{process.env.NODE_ENV !== 'production' ? (
<TanStackDevtools
config={{ position: 'bottom-right' }}
plugins={[{ name: 'Tanstack Router', render: <TanStackRouterDevtoolsPanel /> }]}
/>
) : null}
🟠 HOCH: Kein Self-Service-Passwort-Reset / Unsichere initiale Passwort-Weitergabe
- Dateiverweis:
src/routes/_admin/admin.users.new.tsx(Zeilen 86–101),src/routes/login.tsx - Problem & Risiko:
- Beim Erstellen eines Kunden (
admin.users.new.tsx) muss der Admin ein Klartext-Passwort vergeben, welches als normales Input-Feld (type="text") sichtbar ist. - Admins müssen dieses Initialpasswort unverschlüsselt (per Mail/Chat) an Kunden weitergeben.
- Es gibt im Kunden-Login keinen "Passwort vergessen?"-Flow (
/reset-password). - Kunden haben keine Möglichkeit, ihr Passwort nach dem Erst-Login selbstständig zu ändern.
- Beim Erstellen eines Kunden (
- Lösungsempfehlung:
- Einladungs-Link / Magic-Link Flow: Statt manueller Passwörter generiert der Admin einen zeitlich befristeten Aktivierungslink via Better-Auth Invitation/Reset-Token.
- Implementierung eines standardisierten
Passwort vergessen-Flows imlogin.tsx.
🟠 HOCH: In-Memory Rate Limiting bei verteilten Deployments
- Dateiverweis:
src/lib/auth.ts(Zeilen 39–50) - Problem & Risiko:
Das Rate Limiting ist auf
storage: 'memory'konfiguriert:In Serverless-, Container- oder Node-Cluster-Umgebungen (z. B. Nitro / Cloudflare / Node Multi-Worker) teilt sich jeder Worker einen eigenen Speicher. Bei Server-Neustarts oder mehreren Instanzen ist der Brute-Force-Schutz wirkungslos oder leicht zu umgehen.rateLimit: { enabled: true, storage: 'memory', customRules: { '/sign-in/email': { window: 60, max: 5 }, // ... } } - Lösungsempfehlung: Anbindung eines persistenten Speichers (z. B. Redis / Upstash / Postgres-basiertes Rate Limiting) oder Absicherung über Cloudflare WAF / Reverse Proxy.
🟡 MITTEL: Client-seitiges Data-Fetching mit Wasserfällen im Admin- & Kundenbereich
- Dateiverweis:
src/routes/_admin/admin.index.tsx(Zeilen 25–40),src/routes/_admin/admin.users.$userId.tsx(Zeilen 35–62) - Problem & Architektur-Defizit:
Daten werden per
useEffectund lokalemuseStateclientseitig nachgeladen ("Lade Kunden ...").- Dies widerspricht dem Konzept von TanStack Start / Router (Loader-first SSR).
- Führt zu Layout-Shifts, fehlenden SSR-Vorteilen und erschwert Caching, Revalidierung und granulare Fehlergrenzen (
CatchBoundary).
- Lösungsempfehlung:
Nutzung von
loaderin Kombination mit Server Functions (createServerFn):
// src/routes/_admin/admin.index.tsx
export const Route = createFileRoute('/_admin/admin/')({
loader: async () => await listUsersFn(),
component: AdminUsersPage,
})
function AdminUsersPage() {
const users = Route.useLoaderData()
// Kein useEffect / manual loading state nötig
}
🟡 MITTEL: Fehlende Sicherheits-Header (CSP, HSTS, X-Frame-Options)
- Dateiverweis:
vite.config.ts,src/routes/__root.tsx - Problem & Risiko:
Es sind keine HTTP-Security-Header konfiguriert. Im
__root.tsxwird ein Inline-Script (themeItnitScript) ohne Nonce ausgeführt. - Lösungsempfehlung:
In Nitro/Vite oder im Webserver (Nginx/Cloudflare) Header setzen:
Content-Security-Policy(CSP)X-Frame-Options: DENY(Schutz vor Clickjacking)X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originStrict-Transport-Security: max-age=31536000; includeSubDomains
🟢 NIEDRIG: Fehlende Domain-Zentralisierung & Redundante Rollenprüfungen
- Dateiverweis:
src/components/header.tsx(Zeilen 22–26),src/routes/login.tsx(Zeilen 10, 47),src/components/app-shell.tsx(Zeile 58) - Problem:
Die Weiterleitungs- und Rollenlogik
user.role === 'admin' ? '/admin' : '/dashboard'ist redundant über mehrere Komponenten verstreut. - Lösungsempfehlung:
Zentralisierung in einem Auth-Helper, z. B.
getDefaultRedirect(session)insrc/lib/auth-utils.ts.
2. Konkreter Architektur-Blueprint für Rechnungen & Abnahmen
Um Spaghetti-Code und spätere Umbauten an der Auth-Basis zu vermeiden, sollte die Codebasis jetzt modular strukturiert werden.
A. Empfohlene Feature-basierte Verzeichnisstruktur
src/
├── db/
│ ├── schema/
│ │ ├── auth.ts # user, session, account, verification
│ │ ├── customers.ts # customer / tenant Stammdaten
│ │ ├── projects.ts # Webseiten-Projekte
│ │ ├── invoices.ts # Rechnungen, Posten, Zahlungsstatus
│ │ └── approvals.ts # Produktabnahmen, Feedback-Runden
│ └── index.ts # Drizzle DB Instance & Schema-Export
├── server/
│ ├── middleware/
│ │ ├── auth.ts # Authentifizierungs-Guard
│ │ └── tenant.ts # Mandanten-Isolation (Tenant Context)
│ └── services/ # Pure Business Logic (unabhängig von UI)
│ ├── invoice.service.ts
│ └── approval.service.ts
├── routes/
│ ├── _authenticated/
│ │ ├── route.tsx # Auth Guard & Portal Layout
│ │ ├── dashboard.tsx # Kunden-Übersicht
│ │ ├── rechnungen/ # Rechnungsmodul (Kunde)
│ │ │ ├── index.tsx
│ │ │ └── $invoiceId.tsx
│ │ └── abnahmen/ # Produktabnahmen (Kunde)
│ │ ├── index.tsx
│ │ └── $approvalId.tsx
│ └── _admin/
│ ├── admin.tsx # Admin Layout
│ ├── rechnungen/ # Rechnungsverwaltung (Admin)
│ └── kunden/ # Kundenverwaltung
B. Datenmodell-Erweiterung (src/db/schema/)
// 1. Projekte (Klammer um Rechnungen und Abnahmen)
export const project = pgTable('project', {
id: text('id').primaryKey(),
customerId: text('customer_id').notNull().references(() => customer.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
status: text('status').notNull().default('in_progress'), // 'in_progress' | 'review' | 'live'
stagingUrl: text('staging_url'),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
// 2. Rechnungsmodul
export const invoice = pgTable('invoice', {
id: text('id').primaryKey(),
customerId: text('customer_id').notNull().references(() => customer.id, { onDelete: 'cascade' }),
projectId: text('project_id').references(() => project.id, { onDelete: 'set null' }),
invoiceNumber: text('invoice_number').notNull().unique(), // z. B. RE-2026-0042
amountGrossInCents: integer('amount_gross_in_cents').notNull(),
taxRate: numeric('tax_rate').notNull().default('19.00'),
status: text('status').notNull().default('open'), // 'open' | 'paid' | 'overdue' | 'cancelled'
pdfStoragePath: text('pdf_storage_path').notNull(),
dueDate: timestamp('due_date').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
// 3. Produktabnahme-Modul
export const approval = pgTable('approval', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull().references(() => project.id, { onDelete: 'cascade' }),
customerId: text('customer_id').notNull().references(() => customer.id, { onDelete: 'cascade' }),
version: text('version').notNull(), // z. B. 'v1.0 - Design-Entwurf'
status: text('status').notNull().default('pending'), // 'pending' | 'approved' | 'changes_requested'
feedback: text('feedback'),
approvedAt: timestamp('approved_at'),
approvedByUserId: text('approved_by_user_id').references(() => user.id),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
C. Sichere Server-Function-Middleware (Mandanten-Isolation)
Mit TanStack Start Middleware wird sichergestellt, dass jede Abfrage automatisch auf den Mandanten des eingeloggten Benutzers eingeschränkt ist:
// src/server/middleware/tenant.ts
import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { auth } from '#/lib/auth.ts'
export const tenantAuthMiddleware = createMiddleware().server(async ({ next }) => {
const request = getRequest()
const session = await auth.api.getSession({ headers: request.headers })
if (!session || !session.user) {
throw new Error('UNAUTHORIZED')
}
return next({
context: {
session,
userId: session.user.id,
role: session.user.role,
// Bei Kunden wird die customerId erzwungen; Admins dürfen filtern
customerId: (session.user as any).customerId as string | null,
isAdmin: session.user.role === 'admin',
},
})
})
Verwendung in Server Functions (IDOR-sicher):
// src/server/functions/invoices.ts
import { createServerFn } from '@tanstack/react-start'
import { tenantAuthMiddleware } from '../middleware/tenant'
import { db } from '#/db/index.ts'
import { invoice } from '#/db/schema.ts'
import { eq, and } from 'drizzle-orm'
export const getInvoicesForCurrentCustomer = createServerFn({ method: 'GET' })
.middleware([tenantAuthMiddleware])
.handler(async ({ context }) => {
if (!context.customerId && !context.isAdmin) {
return []
}
// Automatisch auf den Mandanten des eingeloggten Nutzers begrenzt!
return await db.query.invoice.findMany({
where: context.isAdmin
? undefined
: eq(invoice.customerId, context.customerId!),
orderBy: (inv, { desc }) => [desc(inv.createdAt)],
})
})
3. Zusammenfassung des Handlungsbedarfs
| Priorität | Bereich | Maßnahme |
|---|---|---|
| Kritisch | Architektur | Customer/Tenant-Entität im DB-Schema ergänzen & tenantAuthMiddleware etablieren. |
| Kritisch | Sicherheit | Devtools in __root.tsx hinter process.env.NODE_ENV !== 'production' kapseln. |
| Hoch | Auth / UX | Passwort-Reset-Flow & Einladungs-Link für Kunden einrichten (Klartext-Passwortvergabe im Admin ablösen). |
| Mittel | Performance / Code | Admin- und Kunden-Views von useEffect-Fetching auf TanStack Route Loader umstellen. |
| Mittel | Sicherheit | HTTP-Sicherheitsheader (CSP, HSTS, X-Frame-Options) konfigurieren. |
Mit dieser Vorbereitung ist die Basis optimal gehärtet und Rechnungs- sowie Abnahme-Features können sauber als eigenständige Domain-Module angedockt werden.