security(cards): fail-secure dev-stub, headers, rate-limit, dsgvo audit
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:
Till JS 2026-05-12 16:56:03 +02:00
parent 5859e202c5
commit e1ddbf34b3
21 changed files with 832 additions and 80 deletions

View file

@ -36,7 +36,9 @@ export function tierAtLeast(have: Tier, need: Tier): boolean {
}
const MANA_AUTH_URL = process.env.MANA_AUTH_URL ?? 'https://auth.mana.how';
const ALLOW_DEV_STUB = process.env.CARDS_AUTH_DEV_STUB !== 'false';
// Fail-secure: opt-in, nicht opt-out. Vergessene env-var ⇒ Bypass AUS.
// Tests setzen die Variable via vitest.config.ts → tests/setup.ts.
const ALLOW_DEV_STUB = process.env.CARDS_AUTH_DEV_STUB === 'true';
let jwksCache: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJwks() {

View file

@ -0,0 +1,99 @@
import type { Context, MiddlewareHandler } from 'hono';
/**
* Schmale In-Memory-Rate-Limit-Middleware. Sliding window per Key.
*
* Bewusst dependency-frei und ohne Redis Cards läuft als
* Single-Container. Wenn skaliert wird, ist das ein expliziter
* Sprint (Redis oder Cloudflare-Rate-Limiting auf der Edge).
*
* Verwendung:
* r.post('/heavy', rateLimit({ keyOf: ipKey, windowMs: 60_000, max: 30 }), handler)
*
* Identifier-Funktion (`keyOf`) gibt einen String zurück, der den
* Bucket bestimmt. Beispiele: `ipKey`, `userKey`, oder eine Custom-
* Funktion (z.B. service-key-Hash).
*/
interface BucketEntry {
hits: number[];
}
const buckets = new Map<string, BucketEntry>();
const MAX_BUCKETS = 10_000;
export interface RateLimitOptions {
windowMs: number;
max: number;
keyOf: (c: Context) => string;
/** Wenn gesetzt, Bucket-Prefix — nützlich um mehrere Limits über denselben Caller zu trennen. */
scope?: string;
}
export function rateLimit(opts: RateLimitOptions): MiddlewareHandler {
return async (c, next) => {
const id = `${opts.scope ?? 'default'}|${opts.keyOf(c)}`;
const now = Date.now();
const cutoff = now - opts.windowMs;
let bucket = buckets.get(id);
if (!bucket) {
bucket = { hits: [] };
if (buckets.size >= MAX_BUCKETS) evictOldest(now);
buckets.set(id, bucket);
}
bucket.hits = bucket.hits.filter((t) => t > cutoff);
if (bucket.hits.length >= opts.max) {
const oldest = bucket.hits[0]!;
const retryAfterSec = Math.max(1, Math.ceil((oldest + opts.windowMs - now) / 1000));
c.header('Retry-After', String(retryAfterSec));
c.header('X-RateLimit-Limit', String(opts.max));
c.header('X-RateLimit-Remaining', '0');
return c.json(
{ error: 'rate_limited', retry_after_s: retryAfterSec },
429
);
}
bucket.hits.push(now);
c.header('X-RateLimit-Limit', String(opts.max));
c.header('X-RateLimit-Remaining', String(opts.max - bucket.hits.length));
await next();
};
}
/** Caller-IP, hinter Cloudflare/Reverse-Proxy aus X-Forwarded-For. */
export function ipKey(c: Context): string {
const xff = c.req.header('X-Forwarded-For');
if (xff) return xff.split(',')[0]!.trim();
const real = c.req.header('X-Real-IP');
if (real) return real.trim();
return 'unknown';
}
/** Eingeloggter User (für authentifizierte Endpoints). */
export function userKey(c: Context): string {
const userId = c.get('userId' as never) as string | undefined;
return userId ?? ipKey(c);
}
function evictOldest(now: number): void {
// LRU-light: ältester Bucket-Hit fliegt raus. Linear, aber MAX_BUCKETS klein.
let oldestKey: string | null = null;
let oldestTs = Infinity;
for (const [k, v] of buckets) {
const last = v.hits[v.hits.length - 1] ?? 0;
if (last < oldestTs) {
oldestTs = last;
oldestKey = k;
}
}
if (oldestKey) buckets.delete(oldestKey);
void now;
}
/** Nur in Tests: Bucket-State zurücksetzen. */
export function _resetRateLimitForTests(): void {
buckets.clear();
}