security(cards): fail-secure dev-stub, headers, rate-limit, dsgvo audit
Some checks are pending
CI / validate (push) Waiting to run
Some checks are pending
CI / validate (push) Waiting to run
Behebt live verifiziertes Auth-Bypass auf cardecky-api.mana.how (X-User-Id → founder-Tier) und zieht im selben Patch das fehlende Operations-/Compliance-Fundament nach. * Auth-Middleware fail-secure: opt-in via CARDS_AUTH_DEV_STUB="true" (war opt-out, Default true). Compose-Default flipped auf "false", NODE_ENV="production" für cards-api ergänzt, env-Template dokumentiert. vitest.config.ts + tests/setup.ts aktivieren den Stub gezielt für Test-Suiten. * Security-Headers: Hono secureHeaders() in apps/api, SvelteKit hooks.server.ts mit X-Frame/X-Content-Type/Referrer/ HSTS in apps/web. CSP bewusst ausgelassen — eigener Sprint. * CORS-localhost-Whitelist nur außerhalb Prod. * Rate-Limiting (in-memory sliding window, dependency-frei) auf share.receive 60/min/IP, media.upload 30/min/user, decks.generate + decks.from-image 10/min/user, dsgvo.* 10/min/IP. * Health-Endpoint mit echter DB- und MinIO-Probe; /healthz bleibt Liveness, /healthz/details ist Readiness mit 503 bei Failure. * DSGVO-Honesty: storage_ok + storage_error im Response (statt schluckend console.warn), Account-UI zeigt Fehler-Toast. * Audit-Log: strukturierte JSON-Zeile (kind: "audit") auf stdout für /dsgvo/export, /dsgvo/delete, /me/export, /me/delete. * Bug-Fix: duplizierte case "multiple-choice"-Clause in fsrs.ts. Verifiziert: apps/api 17 Files / 104 Tests grün, apps/web check 0 errors. Deploy auf Mac Mini steht noch aus. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5859e202c5
commit
e1ddbf34b3
21 changed files with 832 additions and 80 deletions
25
apps/web/src/hooks.server.ts
Normal file
25
apps/web/src/hooks.server.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* obendrauf setzen — die hier sind die Defense-Tiefe-Schicht für
|
||||
* den Fall, dass die Cloudflare-Schiene aus dem Pfad fällt.
|
||||
*/
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event);
|
||||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
response.headers.set('Strict-Transport-Security', 'max-age=15552000; includeSubDomains');
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
|
@ -21,12 +21,23 @@ export function exportMe() {
|
|||
return api<UserExport>('/api/v1/me/export');
|
||||
}
|
||||
|
||||
export interface DeleteMeResult {
|
||||
deleted: true;
|
||||
user_id: string;
|
||||
/** false ⇒ DB ist gelöscht, aber Storage-Sweep hat Bytes hinterlassen. */
|
||||
storage_ok: boolean;
|
||||
storage_error?: string;
|
||||
counts: {
|
||||
decks: number;
|
||||
import_jobs: number;
|
||||
media_files: number;
|
||||
storage_objects: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Löscht alle Cards-Daten des eingeloggten Users (User-JWT). */
|
||||
export function deleteMe() {
|
||||
return api<{ deleted: true; user_id: string; counts: { decks: number; import_jobs: number } }>(
|
||||
'/api/v1/me/delete',
|
||||
{ method: 'POST' }
|
||||
);
|
||||
return api<DeleteMeResult>('/api/v1/me/delete', { method: 'POST' });
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
|
|
|
|||
|
|
@ -67,9 +67,22 @@
|
|||
deleting = true;
|
||||
try {
|
||||
const r = await deleteMe();
|
||||
toasts.success(
|
||||
t('account.delete_done', { decks: r.counts.decks, imports: r.counts.import_jobs }),
|
||||
);
|
||||
if (r.storage_ok) {
|
||||
toasts.success(
|
||||
t('account.delete_done', {
|
||||
decks: r.counts.decks,
|
||||
imports: r.counts.import_jobs,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// DB sauber, aber Storage-Sweep ist gescheitert. User MUSS das wissen,
|
||||
// sonst denkt er „alles gelöscht" obwohl Bytes liegen geblieben sind.
|
||||
toasts.error(
|
||||
`Daten gelöscht, aber Medien-Speicher konnte nicht vollständig geleert werden${
|
||||
r.storage_error ? ` (${r.storage_error})` : ''
|
||||
}. Bitte den Vereins-Support kontaktieren.`,
|
||||
);
|
||||
}
|
||||
devUser.clear();
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue