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
78
apps/api/tests/rate-limit.test.ts
Normal file
78
apps/api/tests/rate-limit.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import {
|
||||
rateLimit,
|
||||
ipKey,
|
||||
_resetRateLimitForTests,
|
||||
} from '../src/middleware/rate-limit.ts';
|
||||
|
||||
describe('rateLimit middleware', () => {
|
||||
beforeEach(() => {
|
||||
_resetRateLimitForTests();
|
||||
});
|
||||
|
||||
function buildApp(max: number) {
|
||||
const app = new Hono();
|
||||
app.use('/probe', rateLimit({ scope: 't', windowMs: 60_000, max, keyOf: ipKey }));
|
||||
app.get('/probe', (c) => c.json({ ok: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it('lässt Calls unter dem Limit durch und zählt Remaining runter', async () => {
|
||||
const app = buildApp(3);
|
||||
const ip = { 'X-Forwarded-For': '10.0.0.1' };
|
||||
|
||||
const r1 = await app.request('/probe', { headers: ip });
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r1.headers.get('X-RateLimit-Remaining')).toBe('2');
|
||||
|
||||
const r2 = await app.request('/probe', { headers: ip });
|
||||
expect(r2.status).toBe(200);
|
||||
expect(r2.headers.get('X-RateLimit-Remaining')).toBe('1');
|
||||
|
||||
const r3 = await app.request('/probe', { headers: ip });
|
||||
expect(r3.status).toBe(200);
|
||||
expect(r3.headers.get('X-RateLimit-Remaining')).toBe('0');
|
||||
});
|
||||
|
||||
it('antwortet 429 mit Retry-After wenn das Limit überschritten ist', async () => {
|
||||
const app = buildApp(2);
|
||||
const ip = { 'X-Forwarded-For': '10.0.0.2' };
|
||||
|
||||
await app.request('/probe', { headers: ip });
|
||||
await app.request('/probe', { headers: ip });
|
||||
const blocked = await app.request('/probe', { headers: ip });
|
||||
|
||||
expect(blocked.status).toBe(429);
|
||||
expect(blocked.headers.get('Retry-After')).toBeTruthy();
|
||||
const body = (await blocked.json()) as { error: string; retry_after_s: number };
|
||||
expect(body.error).toBe('rate_limited');
|
||||
expect(body.retry_after_s).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('isoliert Buckets pro IP', async () => {
|
||||
const app = buildApp(1);
|
||||
const ipA = { 'X-Forwarded-For': '10.0.0.3' };
|
||||
const ipB = { 'X-Forwarded-For': '10.0.0.4' };
|
||||
|
||||
await app.request('/probe', { headers: ipA });
|
||||
const aBlocked = await app.request('/probe', { headers: ipA });
|
||||
const bAllowed = await app.request('/probe', { headers: ipB });
|
||||
|
||||
expect(aBlocked.status).toBe(429);
|
||||
expect(bAllowed.status).toBe(200);
|
||||
});
|
||||
|
||||
it('benutzt nur den ersten X-Forwarded-For-Hop als Key', async () => {
|
||||
const app = buildApp(1);
|
||||
// Zwei Proxy-Hops im XFF — soll auf den Client-IP-Bucket fallen, nicht
|
||||
// auf den Edge-IP-Bucket.
|
||||
const headers1 = { 'X-Forwarded-For': '203.0.113.5, 10.0.0.9' };
|
||||
const headers2 = { 'X-Forwarded-For': '203.0.113.5, 10.0.0.10' };
|
||||
|
||||
await app.request('/probe', { headers: headers1 });
|
||||
const blocked = await app.request('/probe', { headers: headers2 });
|
||||
expect(blocked.status).toBe(429);
|
||||
});
|
||||
});
|
||||
9
apps/api/tests/setup.ts
Normal file
9
apps/api/tests/setup.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Vitest-Setup. Wird über vitest.config.ts → setupFiles geladen,
|
||||
// bevor irgendein Test-Modul ausgeführt wird.
|
||||
//
|
||||
// Aktiviert den X-User-Id-Dev-Stub für Tests, weil die Suiten ohne
|
||||
// echten mana-auth-JWKS laufen. In Produktion ist der Stub fail-secure
|
||||
// AUS (siehe apps/api/src/middleware/auth.ts).
|
||||
if (process.env.CARDS_AUTH_DEV_STUB === undefined) {
|
||||
process.env.CARDS_AUTH_DEV_STUB = 'true';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue