security(cards): CSP report-only + service-key rotation playbook
Some checks are pending
CI / validate (push) Waiting to run
Some checks are pending
CI / validate (push) Waiting to run
Folge-Hardening zu e1ddbf3, Cluster A2+A3 aus FEATURE_IDEAS.
* hooks.server.ts: restriktive CSP im Report-Only-Modus
(default-src 'self', script-src 'self', connect-src whitelist
auf cardecky-api/auth.mana.how/share/mcp). CARDS_CSP_ENFORCE=true
flippt auf den scharfen Header.
* docs/playbooks/SERVICE_KEY_ROTATION.md: 5-Schritt-Rotation für
CARDS_DSGVO_SERVICE_KEY bis Phase F-1 (mana-auth-managed Keys).
Forensik der Bypass-Periode 2026-05-08 → 2026-05-12 ist abgeschlossen:
nur 2 user_ids in der Cards-DB, beide legitim (tills95@gmail.com +
Smoke-Test-Sentinel c1a5, letztere via DSGVO-Endpoint aufgeräumt).
Kein ausgenutzter Bypass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e1ddbf34b3
commit
5a29dd9a8c
3 changed files with 164 additions and 14 deletions
|
|
@ -3,21 +3,45 @@ import type { Handle } from '@sveltejs/kit';
|
||||||
/**
|
/**
|
||||||
* Server-Hook für Security-Headers auf jeder ausgelieferten Response.
|
* Server-Hook für Security-Headers auf jeder ausgelieferten Response.
|
||||||
*
|
*
|
||||||
* Bewusst minimalistisch: nur die well-known Header, die ohne
|
|
||||||
* Browser-Verhaltens-Test sicher sind. **CSP fehlt absichtlich** —
|
|
||||||
* eine richtige Content-Security-Policy braucht App-Inventur
|
|
||||||
* (inline-styles, externe Theme-Assets, Markdown-Renderer) und
|
|
||||||
* sollte separat mit Live-Test eingeführt werden.
|
|
||||||
*
|
|
||||||
* Cloudflare-Tunnel-Transform-Rules dürfen das gerne nochmal
|
* Cloudflare-Tunnel-Transform-Rules dürfen das gerne nochmal
|
||||||
* obendrauf setzen — die hier sind die Defense-Tiefe-Schicht für
|
* obendrauf setzen — die hier sind die Defense-Tiefe-Schicht für
|
||||||
* den Fall, dass die Cloudflare-Schiene aus dem Pfad fällt.
|
* den Fall, dass die Cloudflare-Schiene aus dem Pfad fällt.
|
||||||
|
*
|
||||||
|
* CSP läuft initial im **Report-Only-Mode**: Browser melden
|
||||||
|
* Violations in die DevTools-Console (`console.warn`), die App
|
||||||
|
* bleibt funktional. Nach 1-2 Tagen Live-Beobachtung muss die
|
||||||
|
* Policy mit echten Reports getunet und auf Enforce geflippt
|
||||||
|
* werden — `CARDS_CSP_ENFORCE=true` aktiviert den scharfen Header.
|
||||||
*/
|
*/
|
||||||
|
const CSP_POLICY = [
|
||||||
|
"default-src 'self'",
|
||||||
|
// 'unsafe-inline' für Svelte-5-Style-Attribute + Theme-CSS-Variables;
|
||||||
|
// langfristig durch nonce-/hash-basierten Style ersetzen.
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"script-src 'self'",
|
||||||
|
"img-src 'self' data: blob:",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
// XHR/fetch-Ziele: eigene API + Auth-Portal + LLM/Share/MCP-Plattform.
|
||||||
|
"connect-src 'self' https://cardecky-api.mana.how https://auth.mana.how https://share.mana.how https://mcp.mana.how",
|
||||||
|
"media-src 'self' blob:",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self' https://auth.mana.how",
|
||||||
|
"object-src 'none'",
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
const response = await resolve(event);
|
const response = await resolve(event);
|
||||||
response.headers.set('X-Frame-Options', 'DENY');
|
response.headers.set('X-Frame-Options', 'DENY');
|
||||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
|
||||||
|
const cspHeader =
|
||||||
|
process.env.CARDS_CSP_ENFORCE === 'true'
|
||||||
|
? 'Content-Security-Policy'
|
||||||
|
: 'Content-Security-Policy-Report-Only';
|
||||||
|
response.headers.set(cspHeader, CSP_POLICY);
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
response.headers.set('Strict-Transport-Security', 'max-age=15552000; includeSubDomains');
|
response.headers.set('Strict-Transport-Security', 'max-age=15552000; includeSubDomains');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -277,10 +277,15 @@ einiges:
|
||||||
DENY, X-Content-Type-Options nosniff, Referrer-Policy
|
DENY, X-Content-Type-Options nosniff, Referrer-Policy
|
||||||
strict-origin-when-cross-origin, plus HSTS (180 Tage,
|
strict-origin-when-cross-origin, plus HSTS (180 Tage,
|
||||||
includeSubDomains) wenn `NODE_ENV=production`.
|
includeSubDomains) wenn `NODE_ENV=production`.
|
||||||
- **CSP bewusst ausgespart** — eine richtige Content-Security-
|
- **CSP läuft Report-Only seit 2026-05-12.** `hooks.server.ts`
|
||||||
Policy braucht Browser-Test (inline-styles, Theme-Assets,
|
setzt `Content-Security-Policy-Report-Only` mit restriktiver
|
||||||
Markdown-Renderer). Eigener Sprint. SVG-Upload-Risiko (oben)
|
Policy (default-src 'self', script-src 'self', img-src
|
||||||
bleibt bis dahin ungemildert.
|
'self' data: blob:, connect-src whitelist auf
|
||||||
|
cardecky-api/auth.mana.how/share/mcp). Violations laufen in
|
||||||
|
die Browser-DevTools-Console. Nach 1-2 Tagen Live-Beobachtung
|
||||||
|
via `CARDS_CSP_ENFORCE=true` auf Enforce flippen. SVG-Upload-
|
||||||
|
Risiko ist dann gemildert (script-src verbietet inline-JS in
|
||||||
|
SVG-Inlinen).
|
||||||
- **CORS lässt `localhost` in Prod durch — gebaut 2026-05-12,
|
- **CORS lässt `localhost` in Prod durch — gebaut 2026-05-12,
|
||||||
noch nicht deployed.** Live-Probe hatte
|
noch nicht deployed.** Live-Probe hatte
|
||||||
`Access-Control-Allow-Origin: http://localhost:9999` gezeigt.
|
`Access-Control-Allow-Origin: http://localhost:9999` gezeigt.
|
||||||
|
|
@ -291,10 +296,10 @@ einiges:
|
||||||
`/api/v1/me/delete` (Self-Service) liefern jetzt `storage_ok: true|false`
|
`/api/v1/me/delete` (Self-Service) liefern jetzt `storage_ok: true|false`
|
||||||
+ optional `storage_error` im Response. Frontend zeigt im
|
+ optional `storage_error` im Response. Frontend zeigt im
|
||||||
Account-Delete-Flow eine Fehler-Toast wenn `storage_ok=false`.
|
Account-Delete-Flow eine Fehler-Toast wenn `storage_ok=false`.
|
||||||
- **Service-Key-Rotation** — ein statisches `CARDS_DSGVO_SERVICE_KEY`
|
- **Service-Key-Rotation-Playbook — gebaut 2026-05-12.**
|
||||||
in env, kein Revoke. Phase F-1 ist in `service-key.ts:11`
|
`docs/playbooks/SERVICE_KEY_ROTATION.md` mit 5-Schritt-Rotation,
|
||||||
geplant (Verifikation gegen `mana-auth.apps.app_service_keys`).
|
Tabus, Phase-F-1-Übergang. Bis die mana-auth-managed Variante
|
||||||
Bis dahin: Rotation-Playbook in `docs/playbooks/` festschreiben.
|
fertig ist, ist das die SoT.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
121
docs/playbooks/SERVICE_KEY_ROTATION.md
Normal file
121
docs/playbooks/SERVICE_KEY_ROTATION.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# Service-Key-Rotation — Cards
|
||||||
|
|
||||||
|
Stand: 2026-05-12.
|
||||||
|
|
||||||
|
`CARDS_DSGVO_SERVICE_KEY` ist heute ein **statischer** Service-Key,
|
||||||
|
gegen den `apps/api/src/middleware/service-key.ts` constant-time-
|
||||||
|
vergleicht. mana-admin verwendet ihn für DSGVO-Fan-Out
|
||||||
|
(`POST /api/v1/dsgvo/{export,delete}`).
|
||||||
|
|
||||||
|
Bis Phase F-1 die Verifikation gegen `mana-auth.apps.app_service_keys`
|
||||||
|
ersetzt, bleibt Rotation manuell. Dieses Playbook hält den Ablauf
|
||||||
|
festgenagelt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wann rotieren
|
||||||
|
|
||||||
|
- **Sofort** bei Verdacht auf Leak (Logs, env-Datei, Container-Image,
|
||||||
|
alte Backups, geleakte Screenshots).
|
||||||
|
- **Routinemäßig** alle 6 Monate.
|
||||||
|
- **Nach Personalwechsel** in der Plattform-Crew.
|
||||||
|
- **Nach jeder Notfall-`docker compose exec`-Session**, in der der
|
||||||
|
Key sichtbar war.
|
||||||
|
|
||||||
|
## Rotation in 5 Schritten
|
||||||
|
|
||||||
|
### 1. Neuen Key generieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 32
|
||||||
|
# Beispiel: msk_2cfa7… (Prefix nach Konvention msk_ für „Mana Service Key")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Neuen Key auf der Prod-Box eintragen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh mana-server
|
||||||
|
cd ~/projects/cards
|
||||||
|
nano infrastructure/.env.production
|
||||||
|
# CARDS_DSGVO_SERVICE_KEY=msk_<neu> ← ersetzen
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.production` ist git-ignored und liegt ausschließlich auf der
|
||||||
|
Box. Nicht ins Repo committen.
|
||||||
|
|
||||||
|
### 3. cards-api neustarten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f infrastructure/docker-compose.production.yml \
|
||||||
|
--env-file infrastructure/.env.production \
|
||||||
|
up -d cards-api
|
||||||
|
```
|
||||||
|
|
||||||
|
Kein Rebuild nötig — env-only-Change.
|
||||||
|
|
||||||
|
### 4. Neuen Key bei den Callern eintragen
|
||||||
|
|
||||||
|
Aktueller Single-Caller: **mana-admin**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# mana-Plattform: env-Datei für mana-admin updaten + Service neustarten
|
||||||
|
ssh mana-server
|
||||||
|
cd ~/projects/mana
|
||||||
|
nano .env.production
|
||||||
|
# CARDS_DSGVO_SERVICE_KEY_FOR_FANOUT=msk_<neu> ← oder wie auch immer
|
||||||
|
# die mana-admin-env-Variable benannt ist
|
||||||
|
docker compose -f infrastructure/docker-compose.production.yml \
|
||||||
|
up -d mana-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn weitere S2S-Caller dazukommen (z.B. mana-research): hier
|
||||||
|
ergänzen.
|
||||||
|
|
||||||
|
### 5. Verifizieren + alten Key invalidiert
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alter Key MUSS jetzt 401 geben:
|
||||||
|
curl -i -H "X-Service-Key: <alter-key>" \
|
||||||
|
https://cardecky-api.mana.how/api/v1/dsgvo/export?user_id=...
|
||||||
|
# Erwartet: 401 service_key_invalid
|
||||||
|
|
||||||
|
# Neuer Key MUSS funktionieren:
|
||||||
|
curl -i -H "X-Service-Key: <neuer-key>" \
|
||||||
|
https://cardecky-api.mana.how/api/v1/dsgvo/export?user_id=...
|
||||||
|
# Erwartet: 200 mit JSON-Bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit-Log-Zeile prüfen:
|
||||||
|
```bash
|
||||||
|
docker logs cards-api --since 5m | grep '"event":"dsgvo.export"' | tail
|
||||||
|
```
|
||||||
|
Sollte den letzten erfolgreichen Aufruf mit `auth_mode: "service-key"`
|
||||||
|
zeigen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was nach Phase F-1 anders wird
|
||||||
|
|
||||||
|
Phase F-1 ersetzt die `serviceKeyAuth()`-Middleware durch
|
||||||
|
Verifikation gegen `mana-auth.apps.app_service_keys`. Damit:
|
||||||
|
|
||||||
|
- Keys leben in der DB, nicht in env.
|
||||||
|
- Rotation = `UPDATE apps.app_service_keys SET key_hash = … WHERE app_id = 'cards'`.
|
||||||
|
- Multiple Keys parallel möglich (overlap-Rotation ohne Downtime).
|
||||||
|
- Revocation via Soft-Delete-Flag.
|
||||||
|
|
||||||
|
Bis dahin: dieses Playbook.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabu
|
||||||
|
|
||||||
|
- **Nie** den Service-Key in Commit-Messages, PR-Beschreibungen oder
|
||||||
|
Chat-Threads klartext hinschreiben.
|
||||||
|
- **Nie** im selben Step neuen Key generieren *und* alten Caller
|
||||||
|
weiterlaufen lassen — kurz Downtime ist OK, blindes Doppel-
|
||||||
|
Akzeptieren ist es nicht. (Die Middleware hat heute nur einen
|
||||||
|
Slot.)
|
||||||
|
- **Nie** `CARDS_DSGVO_SERVICE_KEY` im Compose-File hart vorgeben —
|
||||||
|
bleibt env-var-Reference (`${CARDS_DSGVO_SERVICE_KEY:?missing}`),
|
||||||
|
damit der Compose-Default „fail-loud" ist.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue