Compare commits

...
2 Commits
Author SHA1 Message Date
florian.wilde 19b0751c16 Merge branch 'phase/024-owasp'
Prüfung / pruefung (push) Canceled after 0s
2026-09-18 23:45:35 +02:00
florian.wildeandClaude Opus 5 7775b6211f feat(security): Haertung nach OWASP Top 10 (#024)
Drei echte Luecken gefunden und geschlossen:

1. Die 27 Server Actions verliessen sich allein auf proxy.ts. Jede prueft die
   Sitzung jetzt selbst und schlaegt gegen die Datenbank nach, ob das Konto
   noch gilt - damit lassen sich Sitzungen ueberhaupt erst widerrufen.
2. Der Anmelde-Endpunkt prueft die Herkunft des Formulars; vorher war
   Login-CSRF moeglich.
3. Content-Security-Policy mit Nonce je Anfrage und strict-dynamic.

Dazu: npm audit ist ohne Befund (mysql2 und deepmerge-ts per overrides
angehoben statt Prisma zurueckzustufen), eine Benutzerverwaltung fuer die
Kommandozeile und docs/sicherheit.md mit allem, was bewusst offen bleibt -
keine Rollen, keine Zwei-Faktor-Anmeldung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 23:45:35 +02:00
35 changed files with 525 additions and 48 deletions
+13
View File
@@ -17,6 +17,19 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.15.0] – 2026-09-18
### Hinzugefügt
- `docs/sicherheit.md`: Zuordnung aller Maßnahmen zu den OWASP Top 10, samt der Punkte, die bewusst offen bleiben (#024)
- Content-Security-Policy mit frischem Nonce je Anfrage (#024)
- Sitzungen lassen sich widerrufen: gesperrtes Konto oder neues Passwort beendet laufende Sitzungen sofort (#024)
- Benutzerverwaltung auf der Kommandozeile: `npm run benutzer -- liste|sperren|entsperren|passwort` (#024)
### Behoben
- Server Actions verließen sich allein auf den Schutz in `proxy.ts`; jede der 27 Aktionen prüft die Sitzung jetzt selbst (#024)
- Die Anmeldung prüfte die Herkunft des Formulars nicht – damit war Login-CSRF möglich (#024)
- `npm audit` meldete vier hohe Schwachstellen in Beiwerk von Prisma (`mysql2`, `deepmerge-ts`); über `overrides` angehoben, jetzt ohne Befund (#024)
## [0.14.1] – 2026-09-18 ## [0.14.1] – 2026-09-18
### Behoben ### Behoben
+6 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 9 h 43 min **Gesamt:** 10 h 28 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -29,6 +29,7 @@
| #018 Feldtest auf echten Hosts | 0 h 40 min | | #018 Feldtest auf echten Hosts | 0 h 40 min |
| #022 Cluster-Abgleich | 0 h 25 min | | #022 Cluster-Abgleich | 0 h 25 min |
| #023 Feldtest PBS | 0 h 35 min | | #023 Feldtest PBS | 0 h 35 min |
| #024 OWASP-Härtung | 0 h 45 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -51,7 +52,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend ## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.14.1 **Version:** 0.15.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -138,6 +139,9 @@
- Der erfundene PBS-Testfall hatte den Fehler verdeckt. Lehre: ausgedachte Testdaten bestätigen nur die eigene Annahme - Der erfundene PBS-Testfall hatte den Fehler verdeckt. Lehre: ausgedachte Testdaten bestätigen nur die eigene Annahme
- Uninstall zum ersten Mal auf einem echten Host geprüft – restlos sauber, damit ist #018 vollständig - Uninstall zum ersten Mal auf einem echten Host geprüft – restlos sauber, damit ist #018 vollständig
- Durchsicht nach OWASP (#024): drei echte Lücken gefunden – Server Actions ohne eigene Zugriffsprüfung, Login-CSRF und fehlende CSP. Alle behoben und mit Tests abgesichert
- Erkenntnis: Der Schutz lag vollständig in einer einzigen Schicht (`proxy.ts`). Das fällt erst auf, wenn man die Endpunkte einzeln betrachtet statt die Oberfläche
### Nächster Schritt ### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023) - Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 248 KiB

+34
View File
@@ -0,0 +1,34 @@
# #024 Härtung nach OWASP
**Status:** erledigt
**Meilenstein:** v0.15.0
**Labels:** security, backend
**Geschätzt:** 4 h **Tatsächlich:** 0 h 45 min
## Ziel
Vor der Freigabe als fertiges Produkt eine Durchsicht gegen die OWASP Top 10 – und die gefundenen
Lücken schließen, statt sie zu vermerken.
## Aufgaben
- [x] Zugriffskontrolle: jede Server Action prüft die Sitzung selbst, nicht nur der Proxy
- [x] Sitzungen widerrufbar machen (gesperrtes Konto, neues Passwort)
- [x] Login-CSRF: Herkunft der Anmeldung prüfen
- [x] Content-Security-Policy mit Nonce je Anfrage
- [x] Abhängigkeiten ohne bekannte Schwachstellen (`npm audit`)
- [x] Benutzerverwaltung auf der Kommandozeile (sperren, entsperren, Passwort)
- [x] `docs/sicherheit.md` mit der Zuordnung zu den OWASP Top 10
- [x] Tests für Sitzungsurteil und Herkunftsprüfung
## Gefunden und behoben
1. **Server Actions waren nur durch `proxy.ts` geschützt.** Keine einzige Aktion prüfte selbst. Der
Proxy greift zwar auch bei POST, aber eine einzige Schicht ist zu wenig für 27 schreibende Endpunkte.
2. **Login-CSRF möglich:** Der Anmelde-Endpunkt ist ein gewöhnliches Formular und prüfte die Herkunft
nicht. Eine fremde Seite hätte jemanden in ein untergeschobenes Konto anmelden können.
3. **Keine CSP.** Jetzt mit Nonce und `strict-dynamic`.
4. **Sitzungen ließen sich nicht beenden.** Ein gesperrtes Konto arbeitete bis zu zwölf Stunden weiter.
5. **`npm audit`: 4 hohe Meldungen** in `mysql2` und `deepmerge-ts` – beides Beiwerk von Prisma. Über
`overrides` angehoben statt Prisma zurückzustufen; danach keine Meldung mehr.
## Bewusst nicht umgesetzt
- **Rollen:** Wer angemeldet ist, darf alles. Für ein internes Werkzeug vertretbar, in `docs/sicherheit.md` vermerkt.
- **Zwei-Faktor-Anmeldung:** Wer sie braucht, nutzt OIDC.
- Die Einschränkung der OIDC-Anmeldung (erlaubte Domänen, automatisches Anlegen) kommt mit #025.
+2 -1
View File
@@ -27,4 +27,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min | | [#021](021-geheimnisse-verschluesseln.md) | Zugangsdaten verschlüsselt speichern | erledigt | v0.13.1 | 2 h | 0 h 20 min |
| [#022](022-cluster-abgleich.md) | Cluster-Abgleich | erledigt | v0.14.0 | 3 h | 0 h 25 min | | [#022](022-cluster-abgleich.md) | Cluster-Abgleich | erledigt | v0.14.0 | 3 h | 0 h 25 min |
| [#023](023-feldtest-pbs-pmg.md) | Feldtest auf PBS und PMG | offen | v1.0.0 | 2 h | 0 h 35 min (PBS erledigt, PMG offen) | | [#023](023-feldtest-pbs-pmg.md) | Feldtest auf PBS und PMG | offen | v1.0.0 | 2 h | 0 h 35 min (PBS erledigt, PMG offen) |
| | **Summe** | | | **121 h** | **9 h 43 min** | | [#024](024-owasp-haertung.md) | Härtung nach OWASP | erledigt | v0.15.0 | 4 h | 0 h 45 min |
| | **Summe** | | | **125 h** | **10 h 28 min** |
+101
View File
@@ -0,0 +1,101 @@
# Sicherheit
Dieses Dokument hält fest, was ProxLedger gegen die üblichen Angriffe tut – und was es **nicht** tut.
Die Gliederung folgt den OWASP Top 10 (2021).
## A01 – Fehlerhafte Zugriffskontrolle
- `src/proxy.ts` schickt jede Anfrage ohne gültige Sitzung zur Anmeldung. Öffentlich sind nur die
Anmeldung, die Agent-API (eigene Tokens), die Gesundheitsprüfung, die beiden Agent-Skripte und die Symbole.
- **Jede Server Action prüft zusätzlich selbst** (`sitzungErforderlich()`). Server Actions sind eigene
Endpunkte; eine einzige Schutzschicht davor wäre zu wenig, falls der Musterausdruck im Proxy je
danebengreift.
- Die Prüfung geht in die Datenbank: Ein gesperrtes Konto oder ein Widerruf beendet laufende Sitzungen
sofort (`npm run benutzer -- sperren <email>`), statt bis zum Ablauf des Tokens zu warten.
- Die Anmeldung prüft den `Origin`, damit keine fremde Seite jemanden unbemerkt in ein untergeschobenes
Konto anmeldet (Login-CSRF). Für Server Actions erledigt das Next.js selbst.
**Nicht vorhanden:** Rollen. Wer angemeldet ist, darf alles. Für ein Werkzeug, das ein Systemhaus intern
betreibt, ist das vertretbar – für mehrere Mandanten wäre es das nicht.
## A02 – Kryptografische Fehler
- Passwörter: argon2id (`@node-rs/argon2`), keine eigene Krypto.
- Sitzungen: signiertes JWT (HS256) aus `APP_SECRET`, 12 Stunden gültig, Cookie `httpOnly`, `sameSite=lax`
und `secure`, sobald `APP_URL` auf `https://` steht.
- Zugangsdaten für SMTP und Webhooks liegen AES-256-GCM-verschlüsselt in der Datenbank
(`src/lib/geheimnisse.ts`); der Schlüssel wird aus `APP_SECRET` abgeleitet.
- Agent- und Enrollment-Tokens werden nur als SHA-256-Hash gespeichert. Das ist hier richtig und nicht
zu schwach: Die Tokens sind 32 Byte Zufall, es gibt nichts zu raten.
**Wichtig:** `APP_SECRET` ist der Generalschlüssel. Geht er verloren, sind alle Sitzungen ungültig und die
gespeicherten Zugangsdaten unlesbar. Er gehört in die Sicherung.
## A03 – Injektion
- Datenbankzugriffe ausschließlich über Prisma mit gebundenen Werten; kein `$queryRawUnsafe` im Projekt.
- Ausgaben maskiert React selbst.
- Zusätzlich greift eine Content-Security-Policy mit frischem Nonce je Anfrage und `strict-dynamic`
(`src/proxy.ts`). Eingeschleustes `<script>` – etwa über einen manipulierten Hostnamen aus einer
Proxmox-Ausgabe – läuft damit nicht.
- Der Agent baut sein JSON mit einer eigenen Maskierung; Sonderzeichen aus Proxmox-Ausgaben sind als
Testfall hinterlegt.
## A04 – Unsicheres Design
- Push-Modell: Das Dashboard braucht **keinen** Zugang zu den Proxmox-Hosts. Es gibt keine gespeicherten
root-Zugänge, die abhandenkommen könnten – die Hosts melden von sich aus.
- Der Agent sendet nur Statusangaben (Subscription, Version, Sockel, IPs). Keine Gastliste, keine Daten.
- Enrollment-Tokens sind befristet und in der Zahl der Nutzungen begrenzt.
## A05 – Sicherheitsrelevante Fehlkonfiguration
- Kopfzeilen: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`,
`Strict-Transport-Security` (`next.config.ts`) sowie die CSP aus dem Proxy.
- Die Anwendung startet nicht mit zu kurzem `APP_SECRET`.
- Im Container läuft die Anwendung als unprivilegierter Benutzer; Anhänge werden mit Rechten 640 abgelegt.
- Fehlermeldungen nennen keine Einzelheiten: Bei der Anmeldung steht dieselbe Meldung für „Adresse
unbekannt“ und „Passwort falsch“.
## A06 – Verwundbare Komponenten
- `npm audit --omit=dev` meldet **keine** bekannten Schwachstellen (Stand 18.09.2026).
- Zwei Fassungen werden über `overrides` angehoben: `mysql2` und `deepmerge-ts` kommen als Beiwerk von
Prisma mit, obwohl wir Postgres nutzen. Ohne die Anhebung bliebe nur ein Rückschritt auf Prisma 6.
- Die CI prüft bei jedem Push (#019).
## A07 – Fehler bei Identifizierung und Authentifizierung
- Ratenbremse: fünf Anmeldeversuche je Kennung und Viertelstunde.
- Anmeldung wahlweise per Passwort oder OIDC (authentik, mit PKCE und `state`).
- Jede Anmeldung – erfolgreich oder nicht – landet im Audit-Log.
**Nicht vorhanden:** Zwei-Faktor-Anmeldung. Wer sie braucht, stellt OIDC davor und schaltet die
Passwortanmeldung ab.
## A08 – Datenintegrität
- Der Agent prüft vor dem Selbstaktualisieren, ob die geladene Datei wie ein Bash-Skript beginnt.
- Webhooks werden mit HMAC-SHA256 signiert, damit die Gegenstelle die Herkunft prüfen kann.
- Hochgeladene Rechnungen werden nicht dem gemeldeten Typ nach beurteilt, sondern am Dateianfang
geprüft (`%PDF-`), auf 10 MB begrenzt und unter ihrer Prüfsumme abgelegt.
## A09 – Protokollierung
- Audit-Log für Anmeldungen, Änderungen an Kunden, Rechnungen, Tokens, Kanälen und Warnregeln –
einsehbar unter *Einstellungen → Protokoll*.
- Der Agent protokolliert auf dem Host über `logger` ins Journal.
## A10 – Server-Side Request Forgery
- Die einzige ausgehende Verbindung, die sich einstellen lässt, ist der Benachrichtigungs-Webhook. Er
wird bewusst von einem Angemeldeten eingetragen und ist damit kein fremdgesteuerter Aufruf.
- Sonst ruft das Dashboard keine Adressen auf, die aus Daten stammen.
## Was ein Betreiber selbst tun muss
1. HTTPS davorschalten (Beispiele unter `deploy/reverse-proxy/`) und `APP_URL` auf `https://` setzen.
2. `APP_SECRET` und die Datenbank sichern (`deploy/backup.sh`).
3. Das Dashboard nicht ins offene Internet stellen, wenn es nicht sein muss. Die Agent-API muss
erreichbar sein, die Oberfläche nicht.
4. Enrollment-Tokens nach dem Ausrollen widerrufen.
+39 -38
View File
@@ -1,12 +1,12 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.12.0", "version": "0.14.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxledger", "name": "proxledger",
"version": "0.12.0", "version": "0.14.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
@@ -5071,13 +5071,23 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/deepmerge-ts": { "node_modules/deepmerge-ts": {
"version": "7.1.5", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",
"devOptional": true, "devOptional": true,
"funding": [
{
"type": "ko-fi",
"url": "https://ko-fi.com/rebeccastevens"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
}
],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.9.0"
} }
}, },
"node_modules/define-data-property": { "node_modules/define-data-property": {
@@ -5133,16 +5143,6 @@
"robust-predicates": "^3.0.2" "robust-predicates": "^3.0.2"
} }
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/destr": { "node_modules/destr": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
@@ -7820,24 +7820,25 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/mysql2": { "node_modules/mysql2": {
"version": "3.15.3", "version": "3.24.4",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"aws-ssl-profiles": "^1.1.1", "aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1", "generate-function": "^2.3.1",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.3",
"long": "^5.2.1", "long": "^5.3.2",
"lru.min": "^1.0.0", "lru.min": "^1.1.4",
"named-placeholders": "^1.1.3", "named-placeholders": "^1.1.6",
"seq-queue": "^0.0.5", "sql-escaper": "^1.5.1"
"sqlstring": "^2.3.2"
}, },
"engines": { "engines": {
"node": ">= 8.0" "node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
} }
}, },
"node_modules/named-placeholders": { "node_modules/named-placeholders": {
@@ -9059,12 +9060,6 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==",
"devOptional": true
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -9314,14 +9309,20 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/sqlstring": { "node_modules/sql-escaper": {
"version": "2.3.3", "version": "1.5.2",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
} }
}, },
"node_modules/stable-hash": { "node_modules/stable-hash": {
+7 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.14.1", "version": "0.15.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -26,7 +26,8 @@
"lasttest": "tsx scripts/lasttest.ts", "lasttest": "tsx scripts/lasttest.ts",
"screenshots": "node scripts/screenshots.mjs", "screenshots": "node scripts/screenshots.mjs",
"icons": "node scripts/icons.mjs", "icons": "node scripts/icons.mjs",
"pruefe:doku": "node scripts/pruefe-doku.mjs" "pruefe:doku": "node scripts/pruefe-doku.mjs",
"benutzer": "tsx scripts/benutzer.ts"
}, },
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.2.1", "@node-rs/argon2": "^2.2.1",
@@ -65,5 +66,9 @@
"description": "Proxmox Subscription Dashboard", "description": "Proxmox Subscription Dashboard",
"engines": { "engines": {
"node": ">=24" "node": ">=24"
},
"overrides": {
"mysql2": "^3.24.4",
"deepmerge-ts": "^8.0.0"
} }
} }
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Benutzer" ADD COLUMN "sitzungenUngueltigAb" TIMESTAMP(3);
+2
View File
@@ -456,6 +456,8 @@ model Benutzer {
oidcSubject String? @unique oidcSubject String? @unique
aktiv Boolean @default(true) aktiv Boolean @default(true)
letzterLoginAm DateTime? letzterLoginAm DateTime?
/// Alle Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, gelten als ungültig (#024).
sitzungenUngueltigAb DateTime?
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
+81
View File
@@ -0,0 +1,81 @@
import "dotenv/config";
import { createInterface } from "node:readline/promises";
import { hash } from "@node-rs/argon2";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
/**
* Benutzerverwaltung von der Kommandozeile – solange es dafür keine Oberfläche gibt.
*
* npm run benutzer -- liste
* npm run benutzer -- sperren <email> Konto sperren und alle Sitzungen sofort beenden
* npm run benutzer -- entsperren <email>
* npm run benutzer -- passwort <email> neues Passwort setzen, beendet alle Sitzungen
*/
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
async function passwortAbfragen(): Promise<string> {
const eingabe = createInterface({ input: process.stdin, output: process.stdout });
const passwort = await eingabe.question("Neues Passwort (mindestens 12 Zeichen): ");
eingabe.close();
if (passwort.length < 12) throw new Error("Zu kurz – mindestens 12 Zeichen.");
return passwort;
}
async function main() {
const [befehl, email] = process.argv.slice(2);
if (befehl === "liste") {
const alle = await prisma.benutzer.findMany({ orderBy: { email: "asc" } });
for (const b of alle) {
console.log(`${b.aktiv ? "aktiv " : "gesperrt"} ${b.email.padEnd(32)} ${b.name}`);
}
return;
}
if (!email) throw new Error("Bitte eine E-Mail-Adresse angeben.");
const benutzer = await prisma.benutzer.findUnique({ where: { email: email.toLowerCase().trim() } });
if (!benutzer) throw new Error(`Kein Benutzer mit der Adresse ${email}.`);
switch (befehl) {
case "sperren":
// Sperren allein genügt nicht: Das Sitzungs-Token gilt sonst bis zum Ablauf weiter.
await prisma.benutzer.update({
where: { id: benutzer.id },
data: { aktiv: false, sitzungenUngueltigAb: new Date() },
});
console.log(`${benutzer.email} ist gesperrt, alle Sitzungen sind beendet.`);
break;
case "entsperren":
await prisma.benutzer.update({ where: { id: benutzer.id }, data: { aktiv: true } });
console.log(`${benutzer.email} ist wieder freigegeben.`);
break;
case "passwort": {
const passwort = await passwortAbfragen();
await prisma.benutzer.update({
where: { id: benutzer.id },
data: {
passwortHash: await hash(passwort),
sitzungenUngueltigAb: new Date(),
},
});
console.log(`Passwort gesetzt; bestehende Sitzungen von ${benutzer.email} sind beendet.`);
break;
}
default:
throw new Error("Bekannte Befehle: liste, sperren, entsperren, passwort");
}
}
main()
.catch((fehler: unknown) => {
console.error(fehler instanceof Error ? fehler.message : fehler);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+30
View File
@@ -9,6 +9,32 @@ export const dynamic = "force-dynamic";
* Anmeldung als gewöhnliches Formular. Bewusst kein Server-Action-Weg: So funktioniert die * Anmeldung als gewöhnliches Formular. Bewusst kein Server-Action-Weg: So funktioniert die
* Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie hängt an einer normalen Antwort. * Anmeldung auch ohne JavaScript, und das Sitzungs-Cookie hängt an einer normalen Antwort.
*/ */
/**
* Prüft, ob das Formular von der eigenen Oberfläche kam.
*
* Ohne diese Prüfung könnte eine fremde Seite ein Anmeldeformular auf dieses Dashboard abschicken
* und das Opfer unbemerkt in ein untergeschobenes Konto anmelden (Login-CSRF, OWASP A01).
* Server Actions in Next.js prüfen das selbst; dieser Endpunkt ist ein gewöhnliches Formular
* und muss es daher selbst tun.
*/
export function herkunftStimmt(request: Request): boolean {
const herkunft = request.headers.get("origin");
// Formulare ohne Origin (etwa curl oder alte Browser) werden nicht ausgeschlossen –
// der Schutz richtet sich gegen fremde Seiten, die immer einen Origin senden.
if (!herkunft) return true;
const erlaubt = new Set<string>();
if (process.env.APP_URL) erlaubt.add(new URL(process.env.APP_URL).host);
const gastgeber = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
if (gastgeber) erlaubt.add(gastgeber);
try {
return erlaubt.has(new URL(herkunft).host);
} catch {
return false;
}
}
export async function POST(request: Request) { export async function POST(request: Request) {
const formular = await request.formData(); const formular = await request.formData();
const email = formular.get("email")?.toString().trim() ?? ""; const email = formular.get("email")?.toString().trim() ?? "";
@@ -24,6 +50,10 @@ export async function POST(request: Request) {
return Response.redirect(adresse, 303); return Response.redirect(adresse, 303);
} }
if (!herkunftStimmt(request)) {
return zurueckZurAnmeldung("Die Anmeldung kam nicht von dieser Adresse.");
}
if (!email || !passwort) { if (!email || !passwort) {
return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben."); return zurueckZurAnmeldung("E-Mail-Adresse und Passwort eingeben.");
} }
+7 -1
View File
@@ -8,6 +8,11 @@ import { SignJWT, jwtVerify } from "jose";
export const SITZUNGS_COOKIE = "pl_sitzung"; export const SITZUNGS_COOKIE = "pl_sitzung";
export const SITZUNGSDAUER_STUNDEN = 12; export const SITZUNGSDAUER_STUNDEN = 12;
/** Eine gelesene Sitzung weiß zusätzlich, wann sie ausgestellt wurde – nötig für den Widerruf. */
export interface GeleseneSitzung extends Sitzung {
ausgestelltAm: Date;
}
export interface Sitzung { export interface Sitzung {
benutzerId: string; benutzerId: string;
email: string; email: string;
@@ -37,7 +42,7 @@ export async function sitzungErzeugen(sitzung: Sitzung): Promise<string> {
} }
/** Prüft das Token; bei ungültiger Signatur oder Ablauf kommt null zurück. */ /** Prüft das Token; bei ungültiger Signatur oder Ablauf kommt null zurück. */
export async function sitzungLesen(token: string | undefined): Promise<Sitzung | null> { export async function sitzungLesen(token: string | undefined): Promise<GeleseneSitzung | null> {
if (!token) return null; if (!token) return null;
try { try {
const { payload } = await jwtVerify(token, schluessel(), { issuer: "proxledger" }); const { payload } = await jwtVerify(token, schluessel(), { issuer: "proxledger" });
@@ -47,6 +52,7 @@ export async function sitzungLesen(token: string | undefined): Promise<Sitzung |
email: payload.email, email: payload.email,
name: typeof payload.name === "string" ? payload.name : payload.email, name: typeof payload.name === "string" ? payload.name : payload.email,
herkunft: payload.herkunft === "oidc" ? "oidc" : "passwort", herkunft: payload.herkunft === "oidc" ? "oidc" : "passwort",
ausgestelltAm: new Date((payload.iat ?? 0) * 1000),
}; };
} catch { } catch {
return null; return null;
+46 -3
View File
@@ -23,19 +23,62 @@ const OEFFENTLICH = [
"/manifest.webmanifest", "/manifest.webmanifest",
]; ];
/**
* Content-Security-Policy je Anfrage, mit frischem Nonce.
*
* `strict-dynamic` heißt: Nur Skripte mit diesem Nonce dürfen laufen (und was sie selbst nachladen).
* Damit läuft eingeschleustes `<script>` aus der Datenbank oder einer Proxmox-Ausgabe nicht mehr –
* die zweite Verteidigungslinie hinter der Maskierung durch React (OWASP A03).
*
* `style-src` erlaubt bewusst `unsafe-inline`: Bootstrap und AdminLTE setzen Stile direkt am Element,
* und ein Stil kann keinen Code ausführen.
*/
function richtlinie(nonce: string): string {
const entwicklung = process.env.NODE_ENV !== "production";
return [
"default-src 'self'",
// In der Entwicklung braucht der Hot-Reload eval; im Betrieb nicht.
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
// Verbindungen nur zur eigenen Herkunft; in der Entwicklung zusätzlich der Hot-Reload.
`connect-src 'self'${entwicklung ? " ws: wss:" : ""}`,
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
...(entwicklung ? [] : ["upgrade-insecure-requests"]),
].join("; ");
}
function mitRichtlinie(request: NextRequest, antwort?: NextResponse): NextResponse {
const nonce = crypto.randomUUID().replaceAll("-", "");
const csp = richtlinie(nonce);
// Next.js versieht seine eigenen Skripte mit diesem Nonce, sobald es ihn in der Anfrage findet.
const kopfzeilen = new Headers(request.headers);
kopfzeilen.set("x-nonce", nonce);
kopfzeilen.set("Content-Security-Policy", csp);
const ergebnis = antwort ?? NextResponse.next({ request: { headers: kopfzeilen } });
ergebnis.headers.set("Content-Security-Policy", csp);
return ergebnis;
}
export async function proxy(request: NextRequest) { export async function proxy(request: NextRequest) {
const pfad = request.nextUrl.pathname; const pfad = request.nextUrl.pathname;
if (OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`))) { if (OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`))) {
return NextResponse.next(); return mitRichtlinie(request);
} }
const sitzung = await sitzungLesen(request.cookies.get(SITZUNGS_COOKIE)?.value); const sitzung = await sitzungLesen(request.cookies.get(SITZUNGS_COOKIE)?.value);
if (sitzung) return NextResponse.next(); if (sitzung) return mitRichtlinie(request);
const ziel = new URL("/login", request.url); const ziel = new URL("/login", request.url);
if (pfad !== "/") ziel.searchParams.set("ziel", `${pfad}${request.nextUrl.search}`); if (pfad !== "/") ziel.searchParams.set("ziel", `${pfad}${request.nextUrl.search}`);
return NextResponse.redirect(ziel); return mitRichtlinie(request, NextResponse.redirect(ziel));
} }
export const config = { export const config = {
+5
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { alertsAbgleichen } from "@/server/alerts/abgleich"; import { alertsAbgleichen } from "@/server/alerts/abgleich";
import { REGELN } from "@/server/alerts/regeln"; import { REGELN } from "@/server/alerts/regeln";
import type { AktionsErgebnis } from "./kunden"; import type { AktionsErgebnis } from "./kunden";
@@ -15,6 +16,7 @@ function neuLaden() {
/** Warnung quittieren: sie bleibt offen, ist aber als gesehen markiert. */ /** Warnung quittieren: sie bleibt offen, ist aber als gesehen markiert. */
export async function alertQuittieren(formular: FormData) { export async function alertQuittieren(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -25,6 +27,7 @@ export async function alertQuittieren(formular: FormData) {
/** Warnung von Hand schließen; taucht der Fall weiter auf, öffnet der nächste Abgleich sie erneut. */ /** Warnung von Hand schließen; taucht der Fall weiter auf, öffnet der nächste Abgleich sie erneut. */
export async function alertSchliessen(formular: FormData) { export async function alertSchliessen(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -35,6 +38,7 @@ export async function alertSchliessen(formular: FormData) {
/** Auswertung sofort anstoßen, ohne auf den Worker zu warten. */ /** Auswertung sofort anstoßen, ohne auf den Worker zu warten. */
export async function alertsJetztPruefen(): Promise<AktionsErgebnis> { export async function alertsJetztPruefen(): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const ergebnis = await alertsAbgleichen(); const ergebnis = await alertsAbgleichen();
neuLaden(); neuLaden();
return { return {
@@ -47,6 +51,7 @@ export async function warnregelnSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich();
for (const regel of REGELN) { for (const regel of REGELN) {
const aktiv = formular.get(`aktiv:${regel.name}`) === "on"; const aktiv = formular.get(`aktiv:${regel.name}`) === "on";
const schwellwerte: Record<string, number | number[]> = {}; const schwellwerte: Record<string, number | number[]> = {};
+7
View File
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { verschluesseln } from "@/lib/geheimnisse"; import { verschluesseln } from "@/lib/geheimnisse";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele"; import type { EmailKonfiguration, WebhookKonfiguration } from "@/server/notifications/kanaele";
import { testNachricht } from "@/server/notifications/nachrichten"; import { testNachricht } from "@/server/notifications/nachrichten";
import { ueberEinenKanalSenden, zusammenfassungSenden } from "@/server/notifications/versand"; import { ueberEinenKanalSenden, zusammenfassungSenden } from "@/server/notifications/versand";
@@ -22,6 +23,7 @@ export async function emailKanalSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const id = formular.get("id")?.toString() || null; const id = formular.get("id")?.toString() || null;
const host = formular.get("host")?.toString().trim() ?? ""; const host = formular.get("host")?.toString().trim() ?? "";
const port = Number(formular.get("port") ?? 587); const port = Number(formular.get("port") ?? 587);
@@ -78,6 +80,7 @@ export async function webhookKanalSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const id = formular.get("id")?.toString() || null; const id = formular.get("id")?.toString() || null;
const url = formular.get("url")?.toString().trim() ?? ""; const url = formular.get("url")?.toString().trim() ?? "";
@@ -116,6 +119,7 @@ export async function webhookKanalSpeichern(
} }
export async function kanalLoeschen(formular: FormData) { export async function kanalLoeschen(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -126,6 +130,7 @@ export async function kanalLoeschen(formular: FormData) {
/** Verschickt eine Testnachricht über einen Kanal. */ /** Verschickt eine Testnachricht über einen Kanal. */
export async function testSenden(formular: FormData): Promise<void> { export async function testSenden(formular: FormData): Promise<void> {
await sitzungErforderlich();
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -140,6 +145,7 @@ export async function testSenden(formular: FormData): Promise<void> {
/** Zeitraum der Zusammenfassung speichern (täglich, wöchentlich oder aus). */ /** Zeitraum der Zusammenfassung speichern (täglich, wöchentlich oder aus). */
export async function zusammenfassungSpeichern(formular: FormData) { export async function zusammenfassungSpeichern(formular: FormData) {
await sitzungErforderlich();
const zeitraum = formular.get("zeitraum")?.toString() ?? "aus"; const zeitraum = formular.get("zeitraum")?.toString() ?? "aus";
await prisma.einstellung.upsert({ await prisma.einstellung.upsert({
@@ -154,6 +160,7 @@ export async function zusammenfassungSpeichern(formular: FormData) {
/** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */ /** Zusammenfassung sofort verschicken – praktisch zum Ausprobieren. */
export async function zusammenfassungJetztSenden(formular: FormData): Promise<void> { export async function zusammenfassungJetztSenden(formular: FormData): Promise<void> {
await sitzungErforderlich();
const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich"; const zeitraum = formular.get("zeitraum")?.toString() === "woechentlich" ? "woechentlich" : "taeglich";
await zusammenfassungSenden(zeitraum); await zusammenfassungSenden(zeitraum);
neuLaden(); neuLaden();
+3
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import type { AktionsErgebnis } from "./kunden"; import type { AktionsErgebnis } from "./kunden";
/** Speichert Notizen und Tags eines Hosts. */ /** Speichert Notizen und Tags eines Hosts. */
@@ -10,6 +11,7 @@ export async function hostNotizenSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const hostId = formular.get("hostId")?.toString(); const hostId = formular.get("hostId")?.toString();
if (!hostId) return { fehler: "Host nicht gefunden." }; if (!hostId) return { fehler: "Host nicht gefunden." };
@@ -39,6 +41,7 @@ export async function hostNotizenSpeichern(
/** Ordnet einen einzelnen Host einem Kunden zu (aus der Detailansicht heraus). */ /** Ordnet einen einzelnen Host einem Kunden zu (aus der Detailansicht heraus). */
export async function hostKundeSetzen(formular: FormData) { export async function hostKundeSetzen(formular: FormData) {
await sitzungErforderlich();
const hostId = formular.get("hostId")?.toString(); const hostId = formular.get("hostId")?.toString();
const kundeId = formular.get("kundeId")?.toString() || null; const kundeId = formular.get("kundeId")?.toString() || null;
const standortId = formular.get("standortId")?.toString() || null; const standortId = formular.get("standortId")?.toString() || null;
+3
View File
@@ -3,11 +3,13 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { hostsZuweisenService, ZuweisungsFehler } from "@/server/services/zuweisung"; import { hostsZuweisenService, ZuweisungsFehler } from "@/server/services/zuweisung";
import type { AktionsErgebnis } from "./kunden"; import type { AktionsErgebnis } from "./kunden";
/** Weist mehrere Hosts in einem Schritt einem Kunden zu. */ /** Weist mehrere Hosts in einem Schritt einem Kunden zu. */
export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> { export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const hostIds = formular.getAll("hostId").map(String).filter(Boolean); const hostIds = formular.getAll("hostId").map(String).filter(Boolean);
const kundeId = formular.get("kundeId")?.toString() || null; const kundeId = formular.get("kundeId")?.toString() || null;
const standortId = formular.get("standortId")?.toString() || null; const standortId = formular.get("standortId")?.toString() || null;
@@ -30,6 +32,7 @@ export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData
/** Hebt die Zuordnung eines einzelnen Hosts auf. */ /** Hebt die Zuordnung eines einzelnen Hosts auf. */
export async function hostZuordnungAufheben(formular: FormData) { export async function hostZuordnungAufheben(formular: FormData) {
await sitzungErforderlich();
const hostId = formular.get("hostId")?.toString(); const hostId = formular.get("hostId")?.toString();
if (!hostId) return; if (!hostId) return;
+5
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export interface AktionsErgebnis { export interface AktionsErgebnis {
fehler?: string; fehler?: string;
@@ -28,6 +29,7 @@ const kundeSchema = z.object({
/** Legt einen Kunden an oder ändert ihn; `id` im Formular entscheidet darüber. */ /** Legt einen Kunden an oder ändert ihn; `id` im Formular entscheidet darüber. */
export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> { export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const id = leerZuNull(formular.get("id")); const id = leerZuNull(formular.get("id"));
const eingabe = kundeSchema.safeParse({ const eingabe = kundeSchema.safeParse({
name: formular.get("name")?.toString().trim() ?? "", name: formular.get("name")?.toString().trim() ?? "",
@@ -66,6 +68,7 @@ export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormDat
} }
export async function kundeLoeschen(formular: FormData) { export async function kundeLoeschen(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -91,6 +94,7 @@ export async function standortSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const id = leerZuNull(formular.get("id")); const id = leerZuNull(formular.get("id"));
const eingabe = standortSchema.safeParse({ const eingabe = standortSchema.safeParse({
kundeId: formular.get("kundeId")?.toString() ?? "", kundeId: formular.get("kundeId")?.toString() ?? "",
@@ -116,6 +120,7 @@ export async function standortSpeichern(
} }
export async function standortLoeschen(formular: FormData) { export async function standortLoeschen(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
const kundeId = formular.get("kundeId")?.toString(); const kundeId = formular.get("kundeId")?.toString();
if (!id) return; if (!id) return;
+7
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { anhangSpeichern, UploadFehler } from "@/lib/uploads"; import { anhangSpeichern, UploadFehler } from "@/lib/uploads";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { import {
einkaufBezahltSetzen, einkaufBezahltSetzen,
kundenrechnungErfassen, kundenrechnungErfassen,
@@ -48,6 +49,7 @@ async function ausfuehren(arbeit: () => Promise<string | void>): Promise<Aktions
} }
export async function laufzeitBestaetigenAktion(formular: FormData) { export async function laufzeitBestaetigenAktion(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("laufzeitId")?.toString(); const id = formular.get("laufzeitId")?.toString();
if (!id) return; if (!id) return;
await laufzeitBestaetigen(id); await laufzeitBestaetigen(id);
@@ -55,6 +57,7 @@ export async function laufzeitBestaetigenAktion(formular: FormData) {
} }
export async function laufzeitVerwerfenAktion(formular: FormData) { export async function laufzeitVerwerfenAktion(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("laufzeitId")?.toString(); const id = formular.get("laufzeitId")?.toString();
if (!id) return; if (!id) return;
await laufzeitVerwerfen(id); await laufzeitVerwerfen(id);
@@ -63,6 +66,7 @@ export async function laufzeitVerwerfenAktion(formular: FormData) {
/** Häkchen aus der Rechnungstabelle. */ /** Häkchen aus der Rechnungstabelle. */
export async function statusUmschalten(formular: FormData): Promise<void> { export async function statusUmschalten(formular: FormData): Promise<void> {
await sitzungErforderlich();
const laufzeitId = formular.get("laufzeitId")?.toString(); const laufzeitId = formular.get("laufzeitId")?.toString();
const feld = formular.get("feld")?.toString(); const feld = formular.get("feld")?.toString();
const wert = formular.get("wert")?.toString() === "true"; const wert = formular.get("wert")?.toString() === "true";
@@ -84,6 +88,7 @@ export async function proxmoxRechnungSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const laufzeitId = formular.get("laufzeitId")?.toString(); const laufzeitId = formular.get("laufzeitId")?.toString();
if (!laufzeitId) return { fehler: "Laufzeit fehlt." }; if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
@@ -121,6 +126,7 @@ export async function kundenrechnungSpeichern(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const laufzeitId = formular.get("laufzeitId")?.toString(); const laufzeitId = formular.get("laufzeitId")?.toString();
if (!laufzeitId) return { fehler: "Laufzeit fehlt." }; if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
@@ -151,6 +157,7 @@ export async function laufzeitAnlegenAktion(
_vorher: AktionsErgebnis, _vorher: AktionsErgebnis,
formular: FormData, formular: FormData,
): Promise<AktionsErgebnis> { ): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const hostId = formular.get("hostId")?.toString(); const hostId = formular.get("hostId")?.toString();
const key = formular.get("key")?.toString().trim(); const key = formular.get("key")?.toString().trim();
const start = datum(formular.get("start")); const start = datum(formular.get("start"));
+4
View File
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { erzeugeToken } from "@/lib/tokens"; import { erzeugeToken } from "@/lib/tokens";
import { protokolliere } from "@/server/audit"; import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import type { AktionsErgebnis } from "./kunden"; import type { AktionsErgebnis } from "./kunden";
export interface TokenErgebnis extends AktionsErgebnis { export interface TokenErgebnis extends AktionsErgebnis {
@@ -16,6 +17,7 @@ export async function enrollmentTokenErzeugen(
_vorher: TokenErgebnis, _vorher: TokenErgebnis,
formular: FormData, formular: FormData,
): Promise<TokenErgebnis> { ): Promise<TokenErgebnis> {
await sitzungErforderlich();
const bezeichnung = formular.get("bezeichnung")?.toString().trim() || null; const bezeichnung = formular.get("bezeichnung")?.toString().trim() || null;
const kundeId = formular.get("kundeId")?.toString() || null; const kundeId = formular.get("kundeId")?.toString() || null;
const standortId = formular.get("standortId")?.toString() || null; const standortId = formular.get("standortId")?.toString() || null;
@@ -58,6 +60,7 @@ export async function enrollmentTokenErzeugen(
} }
export async function enrollmentTokenWiderrufen(formular: FormData) { export async function enrollmentTokenWiderrufen(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
if (!id) return; if (!id) return;
@@ -67,6 +70,7 @@ export async function enrollmentTokenWiderrufen(formular: FormData) {
} }
export async function hostTokenWiderrufen(formular: FormData) { export async function hostTokenWiderrufen(formular: FormData) {
await sitzungErforderlich();
const id = formular.get("id")?.toString(); const id = formular.get("id")?.toString();
const hostId = formular.get("hostId")?.toString(); const hostId = formular.get("hostId")?.toString();
if (!id) return; if (!id) return;
+44
View File
@@ -0,0 +1,44 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/db";
import { type GeleseneSitzung, SITZUNGS_COOKIE, sitzungLesen } from "@/lib/sitzung";
/**
* Verlangt eine gültige Sitzung und prüft sie gegen die Datenbank.
*
* `proxy.ts` hält Unangemeldete von den Seiten fern, aber Server Actions sind eigene
* Endpunkte: Jede Aktion prüft deshalb selbst, statt sich auf eine einzige Schicht zu
* verlassen (OWASP A01 – Broken Access Control).
*
* Zusätzlich wirkt hier der Widerruf: Ein gesperrtes Konto oder ein zurückgesetztes
* Passwort beendet laufende Sitzungen sofort, statt bis zum Ablauf des Tokens zu warten.
*/
/** Reines Urteil über eine gelesene Sitzung – ohne Next.js, damit es sich prüfen lässt. */
export function sitzungBewerten(
sitzung: GeleseneSitzung | null,
benutzer: { aktiv: boolean; sitzungenUngueltigAb: Date | null } | null,
): { gueltig: true } | { gueltig: false; grund: string } {
if (!sitzung) return { gueltig: false, grund: "Bitte anmelden." };
if (!benutzer) return { gueltig: false, grund: "Dieses Konto gibt es nicht mehr." };
if (!benutzer.aktiv) return { gueltig: false, grund: "Dieses Konto ist gesperrt." };
if (benutzer.sitzungenUngueltigAb && sitzung.ausgestelltAm <= benutzer.sitzungenUngueltigAb) {
return { gueltig: false, grund: "Die Sitzung wurde beendet. Bitte neu anmelden." };
}
return { gueltig: true };
}
export async function sitzungErforderlich(): Promise<GeleseneSitzung> {
const sitzung = await sitzungLesen((await cookies()).get(SITZUNGS_COOKIE)?.value);
const benutzer = sitzung
? await prisma.benutzer.findUnique({
where: { id: sitzung.benutzerId },
select: { aktiv: true, sitzungenUngueltigAb: true },
})
: null;
const urteil = sitzungBewerten(sitzung, benutzer);
if (!urteil.gueltig) redirect(`/login?fehler=${encodeURIComponent(urteil.grund)}`);
return sitzung as GeleseneSitzung;
}
+3 -1
View File
@@ -25,12 +25,14 @@ describe("Sitzungen", () => {
}); });
const sitzung = await sitzungLesen(token); const sitzung = await sitzungLesen(token);
expect(sitzung).toEqual({ expect(sitzung).toMatchObject({
benutzerId: "b1", benutzerId: "b1",
email: "admin@example.de", email: "admin@example.de",
name: "Florian", name: "Florian",
herkunft: "passwort", herkunft: "passwort",
}); });
// Der Ausstellungszeitpunkt entscheidet, ob ein Widerruf die Sitzung trifft (#024).
expect(sitzung?.ausgestelltAm.getTime()).toBeLessThanOrEqual(Date.now());
}); });
it("weist ein verändertes oder fremd signiertes Token ab", async () => { it("weist ein verändertes oder fremd signiertes Token ab", async () => {
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { herkunftStimmt } from "@/app/api/auth/anmelden/route";
import type { GeleseneSitzung } from "@/lib/sitzung";
import { sitzungBewerten } from "@/server/auth/sitzungspflicht";
const sitzung = (ausgestelltAm: Date): GeleseneSitzung => ({
benutzerId: "b1",
email: "admin@example.de",
name: "Administrator",
herkunft: "passwort",
ausgestelltAm,
});
const anfrage = (kopfzeilen: Record<string, string>) =>
new Request("https://proxledger.example/api/auth/anmelden", { method: "POST", headers: kopfzeilen });
describe("Sitzungsprüfung", () => {
const jetzt = new Date("2026-09-18T12:00:00Z");
const aktiv = { aktiv: true, sitzungenUngueltigAb: null };
it("lässt eine gültige Sitzung eines aktiven Kontos durch", () => {
expect(sitzungBewerten(sitzung(jetzt), aktiv)).toEqual({ gueltig: true });
});
it("weist ab, wenn gar keine Sitzung da ist", () => {
expect(sitzungBewerten(null, aktiv).gueltig).toBe(false);
});
it("weist ein gesperrtes Konto ab, obwohl das Token noch gilt", () => {
const urteil = sitzungBewerten(sitzung(jetzt), { aktiv: false, sitzungenUngueltigAb: null });
expect(urteil).toEqual({ gueltig: false, grund: "Dieses Konto ist gesperrt." });
});
it("weist ein gelöschtes Konto ab", () => {
expect(sitzungBewerten(sitzung(jetzt), null).gueltig).toBe(false);
});
it("beendet Sitzungen, die vor dem Widerruf ausgestellt wurden", () => {
const widerruf = new Date("2026-09-18T13:00:00Z");
const urteil = sitzungBewerten(sitzung(jetzt), { aktiv: true, sitzungenUngueltigAb: widerruf });
expect(urteil.gueltig).toBe(false);
});
it("lässt Sitzungen gelten, die nach dem Widerruf entstanden sind", () => {
const widerruf = new Date("2026-09-18T11:00:00Z");
expect(sitzungBewerten(sitzung(jetzt), { aktiv: true, sitzungenUngueltigAb: widerruf })).toEqual({
gueltig: true,
});
});
});
describe("Herkunft der Anmeldung", () => {
it("lässt das eigene Formular durch", () => {
expect(herkunftStimmt(anfrage({ origin: "https://proxledger.example", host: "proxledger.example" }))).toBe(true);
});
it("weist eine fremde Seite ab", () => {
expect(herkunftStimmt(anfrage({ origin: "https://boese.example", host: "proxledger.example" }))).toBe(false);
});
it("weist einen unbrauchbaren Origin ab", () => {
expect(herkunftStimmt(anfrage({ origin: "kein-url", host: "proxledger.example" }))).toBe(false);
});
it("lässt Anfragen ohne Origin zu (curl, alte Browser)", () => {
expect(herkunftStimmt(anfrage({ host: "proxledger.example" }))).toBe(true);
});
it("erkennt den Host hinter einem Reverse Proxy", () => {
expect(
herkunftStimmt(anfrage({ origin: "https://dashboard.kunde.de", "x-forwarded-host": "dashboard.kunde.de" })),
).toBe(true);
});
});