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
|
|
@ -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() {
|
||||
|
|
|
|||
99
apps/api/src/middleware/rate-limit.ts
Normal file
99
apps/api/src/middleware/rate-limit.ts
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue