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
|
|
@ -1,5 +1,6 @@
|
|||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { secureHeaders } from 'hono/secure-headers';
|
||||
|
||||
import { manifestRoute } from './routes/manifest.ts';
|
||||
import { healthRoute } from './routes/health.ts';
|
||||
|
|
@ -25,14 +26,23 @@ import { discussionsRouter as marketplaceDiscussionsRouter } from './routes/mark
|
|||
|
||||
const app = new Hono();
|
||||
|
||||
const IS_PROD = process.env.NODE_ENV === 'production';
|
||||
|
||||
app.use('*', secureHeaders());
|
||||
|
||||
app.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: (origin) => {
|
||||
if (!origin) return origin;
|
||||
// Dev: localhost-Ports erlaubt. Prod: explizite Whitelist.
|
||||
if (/^https?:\/\/localhost(:\d+)?$/.test(origin)) return origin;
|
||||
if (/^https?:\/\/127\.0\.0\.1(:\d+)?$/.test(origin)) return origin;
|
||||
// Lokal-Dev: localhost-Ports erlaubt. Prod: keine localhost-Origins,
|
||||
// damit eine lokale Dev-Instanz nicht Cross-Origin gegen die Live-API
|
||||
// arbeiten kann (Defense-in-Depth — JWT liegt zwar per-Origin, aber
|
||||
// die localhost-Allowance war eine offene Tür).
|
||||
if (!IS_PROD) {
|
||||
if (/^https?:\/\/localhost(:\d+)?$/.test(origin)) return origin;
|
||||
if (/^https?:\/\/127\.0\.0\.1(:\d+)?$/.test(origin)) return origin;
|
||||
}
|
||||
if (origin === 'https://cardecky.mana.how') return origin;
|
||||
return null;
|
||||
},
|
||||
|
|
|
|||
34
apps/api/src/lib/audit.ts
Normal file
34
apps/api/src/lib/audit.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Audit-Log für sicherheits-/compliance-relevante Aktionen.
|
||||
*
|
||||
* Schreibt eine strukturierte JSON-Zeile auf stdout (`console.info`).
|
||||
* In Produktion landet das im Container-Log und kann später per
|
||||
* Promtail/Loki oder ähnlich aggregiert werden. Persistierung in
|
||||
* eine `audit_log`-Tabelle ist ein eigener Sprint — wenn die fällig
|
||||
* wird, muss nur diese Funktion erweitert werden, alle Aufrufer
|
||||
* bleiben gleich.
|
||||
*
|
||||
* Ereignis-Naming-Konvention: `<domäne>.<aktion>`, lower_snake_case.
|
||||
* Bsp.: `dsgvo.export`, `dsgvo.delete`, `auth.bypass_attempt`.
|
||||
*/
|
||||
|
||||
export interface AuditEvent {
|
||||
event: string;
|
||||
actor_user_id?: string;
|
||||
target_user_id?: string;
|
||||
auth_mode?: 'jwt' | 'dev-stub' | 'service-key';
|
||||
ip?: string | null;
|
||||
user_agent?: string | null;
|
||||
result: 'success' | 'failure' | 'partial';
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function auditLog(event: AuditEvent): void {
|
||||
const line = {
|
||||
kind: 'audit',
|
||||
ts: new Date().toISOString(),
|
||||
...event,
|
||||
};
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(JSON.stringify(line));
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||
import { rateLimit, userKey } from '../middleware/rate-limit.ts';
|
||||
import { chatVisionJson } from '../services/llm-client.ts';
|
||||
import { GeneratedDeckSchema, insertGeneratedDeck } from './decks-generate.ts';
|
||||
import { fetchUrlContent } from '../lib/url-fetch.ts';
|
||||
|
|
@ -51,6 +52,8 @@ export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables
|
|||
const dbOf = () => deps.db ?? getDb();
|
||||
|
||||
r.use('*', authMiddleware);
|
||||
// 10/min per User — Vision-LLM-Call ist besonders teuer.
|
||||
r.use('/', rateLimit({ scope: 'decks.from-image', windowMs: 60_000, max: 10, keyOf: userKey }));
|
||||
|
||||
r.post('/', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { makeInitialReviewRows } from '../lib/reviews.ts';
|
|||
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||
import { cards, decks, reviews } from '../db/schema/index.ts';
|
||||
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||
import { rateLimit, userKey } from '../middleware/rate-limit.ts';
|
||||
import { ulid } from '../lib/ulid.ts';
|
||||
import { chatJson } from '../services/llm-client.ts';
|
||||
import { fetchUrlContent } from '../lib/url-fetch.ts';
|
||||
|
|
@ -130,6 +131,8 @@ export function decksGenerateRouter(deps: GenerateDeps = {}): Hono<{ Variables:
|
|||
const dbOf = () => deps.db ?? getDb();
|
||||
|
||||
r.use('*', authMiddleware);
|
||||
// 10/min per User — LLM-Call ist teuer (mana-llm-Credits).
|
||||
r.use('/', rateLimit({ scope: 'decks.generate', windowMs: 60_000, max: 10, keyOf: userKey }));
|
||||
|
||||
r.post('/', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ import {
|
|||
tags,
|
||||
} from '../db/schema/index.ts';
|
||||
import { serviceKeyAuth } from '../middleware/service-key.ts';
|
||||
import { rateLimit, ipKey } from '../middleware/rate-limit.ts';
|
||||
import { getStorage } from '../services/storage.ts';
|
||||
import { auditLog } from '../lib/audit.ts';
|
||||
|
||||
export type DsgvoDeps = { db?: CardsDb };
|
||||
|
||||
|
|
@ -98,6 +100,9 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono {
|
|||
const r = new Hono();
|
||||
const dbOf = () => deps.db ?? getDb();
|
||||
|
||||
// IP-Rate-Limit als Defense-in-Depth — der Service-Key ist ohnehin
|
||||
// Pflicht, aber 10/min stoppt einen brute-force-Versuch auf den Key.
|
||||
r.use('*', rateLimit({ scope: 'dsgvo', windowMs: 60_000, max: 10, keyOf: ipKey }));
|
||||
r.use('*', serviceKeyAuth({ envVar: 'CARDS_DSGVO_SERVICE_KEY' }));
|
||||
|
||||
/**
|
||||
|
|
@ -108,7 +113,20 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono {
|
|||
r.get('/export', async (c) => {
|
||||
const userId = c.req.query('user_id');
|
||||
if (!userId) return c.json({ error: 'missing_user_id' }, 400);
|
||||
return c.json(await buildUserExport(dbOf(), userId));
|
||||
const payload = await buildUserExport(dbOf(), userId);
|
||||
auditLog({
|
||||
event: 'dsgvo.export',
|
||||
target_user_id: userId,
|
||||
auth_mode: 'service-key',
|
||||
ip: c.req.header('X-Forwarded-For') ?? null,
|
||||
user_agent: c.req.header('User-Agent') ?? null,
|
||||
result: 'success',
|
||||
detail: {
|
||||
decks: payload.data.decks.length,
|
||||
cards: payload.data.cards.length,
|
||||
},
|
||||
});
|
||||
return c.json(payload);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -148,18 +166,43 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono {
|
|||
);
|
||||
|
||||
// MinIO-Bucket-Sweep nach DB-Cleanup. Wenn der Storage-Sweep
|
||||
// scheitert, ist das nicht-fatal — die DB ist schon konsistent
|
||||
// gelöscht und Storage-Files ohne DB-Eintrag sind tote Bytes.
|
||||
// scheitert, ist das nicht-fatal für die DB-Konsistenz (Files
|
||||
// ohne DB-Eintrag sind tote Bytes), aber der Caller MUSS es
|
||||
// erfahren — sonst meldet mana-admin dem User „alles gelöscht"
|
||||
// obwohl Media-Objekte überleben können.
|
||||
let storageObjectsDeleted = 0;
|
||||
let storageOk = true;
|
||||
let storageError: string | null = null;
|
||||
try {
|
||||
storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`);
|
||||
} catch (err) {
|
||||
storageOk = false;
|
||||
storageError = err instanceof Error ? err.message : String(err);
|
||||
console.warn('[dsgvo/delete] storage sweep failed:', err);
|
||||
}
|
||||
|
||||
auditLog({
|
||||
event: 'dsgvo.delete',
|
||||
target_user_id: userId,
|
||||
auth_mode: 'service-key',
|
||||
ip: c.req.header('X-Forwarded-For') ?? null,
|
||||
user_agent: c.req.header('User-Agent') ?? null,
|
||||
result: storageOk ? 'success' : 'partial',
|
||||
detail: {
|
||||
decks: deletedDecks.length,
|
||||
import_jobs: deletedImports.length,
|
||||
media_files: deletedMediaFiles.length,
|
||||
storage_objects: storageObjectsDeleted,
|
||||
storage_ok: storageOk,
|
||||
storage_error: storageError,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({
|
||||
deleted: true,
|
||||
user_id: userId,
|
||||
storage_ok: storageOk,
|
||||
...(storageError ? { storage_error: storageError } : {}),
|
||||
counts: {
|
||||
decks: deletedDecks.length,
|
||||
import_jobs: deletedImports.length,
|
||||
|
|
|
|||
|
|
@ -1,22 +1,44 @@
|
|||
import { Hono } from 'hono';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import { getDb } from '../db/connection.ts';
|
||||
import { getStorage } from '../services/storage.ts';
|
||||
|
||||
export const healthRoute = new Hono();
|
||||
|
||||
/**
|
||||
* Liveness — antwortet immer 200, solange der Prozess läuft.
|
||||
* Bewusst KEIN Downstream-Probe, damit ein kurzer DB-/MinIO-Hänger
|
||||
* nicht den Container in eine Restart-Schleife zwingt.
|
||||
*/
|
||||
healthRoute.get('/healthz', (c) => c.json({ status: 'ok' }));
|
||||
|
||||
healthRoute.get('/healthz/details', (c) =>
|
||||
c.json({
|
||||
status: 'ok',
|
||||
app: 'cards',
|
||||
version: process.env.CARDS_API_VERSION ?? '0.0.0',
|
||||
uptime_s: Math.floor(process.uptime()),
|
||||
mana_packages: {
|
||||
// In Phase 5 mit @mana/shared-app-tpl ersetzen.
|
||||
'@mana/shared-share-protocol': 'TBD',
|
||||
'@mana/shared-app-tpl': 'TBD',
|
||||
/**
|
||||
* Readiness mit Downstream-Probes. Status 200 wenn DB + MinIO grün,
|
||||
* sonst 503 mit Aufschlüsselung welche Probe fehlgeschlagen ist.
|
||||
* Probes timen sich selbst aus über ein 1s-AbortSignal — eine träge
|
||||
* Abhängigkeit darf das Readiness-Signal nicht hängen lassen.
|
||||
*/
|
||||
healthRoute.get('/healthz/details', async (c) => {
|
||||
const [dbProbe, storageProbe] = await Promise.all([
|
||||
probeDb(),
|
||||
probeStorage(),
|
||||
]);
|
||||
const allOk = dbProbe.ok && storageProbe.ok;
|
||||
return c.json(
|
||||
{
|
||||
status: allOk ? 'ok' : 'degraded',
|
||||
app: 'cards',
|
||||
version: process.env.CARDS_API_VERSION ?? '0.0.0',
|
||||
uptime_s: Math.floor(process.uptime()),
|
||||
checks: {
|
||||
db: dbProbe,
|
||||
storage: storageProbe,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
allOk ? 200 : 503
|
||||
);
|
||||
});
|
||||
|
||||
healthRoute.get('/version', (c) =>
|
||||
c.json({
|
||||
|
|
@ -25,3 +47,31 @@ healthRoute.get('/version', (c) =>
|
|||
build: process.env.CARDS_BUILD_SHA ?? 'dev',
|
||||
})
|
||||
);
|
||||
|
||||
type ProbeResult = { ok: true; latency_ms: number } | { ok: false; error: string };
|
||||
|
||||
async function probeDb(): Promise<ProbeResult> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { ok: true, latency_ms: Date.now() - t0 };
|
||||
} catch (err) {
|
||||
return { ok: false, error: errorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function probeStorage(): Promise<ProbeResult> {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
await getStorage().ensureBucket();
|
||||
return { ok: true, latency_ms: Date.now() - t0 };
|
||||
} catch (err) {
|
||||
return { ok: false, error: errorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { cards, decks, importJobs, mediaFiles, reviews } from '../db/schema/inde
|
|||
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||
import { getStorage } from '../services/storage.ts';
|
||||
import { buildUserExport } from './dsgvo.ts';
|
||||
import { auditLog } from '../lib/audit.ts';
|
||||
|
||||
export type MeDeps = { db?: CardsDb };
|
||||
|
||||
|
|
@ -24,7 +25,21 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
|
|||
/** Voll-Export der eigenen Daten als JSON. */
|
||||
r.get('/export', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
return c.json(await buildUserExport(dbOf(), userId));
|
||||
const payload = await buildUserExport(dbOf(), userId);
|
||||
auditLog({
|
||||
event: 'dsgvo.export',
|
||||
actor_user_id: userId,
|
||||
target_user_id: userId,
|
||||
auth_mode: c.get('authMode'),
|
||||
ip: c.req.header('X-Forwarded-For') ?? null,
|
||||
user_agent: c.req.header('User-Agent') ?? null,
|
||||
result: 'success',
|
||||
detail: {
|
||||
decks: payload.data.decks.length,
|
||||
cards: payload.data.cards.length,
|
||||
},
|
||||
});
|
||||
return c.json(payload);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -188,15 +203,39 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
|
|||
);
|
||||
|
||||
let storageObjectsDeleted = 0;
|
||||
let storageOk = true;
|
||||
let storageError: string | null = null;
|
||||
try {
|
||||
storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`);
|
||||
} catch (err) {
|
||||
storageOk = false;
|
||||
storageError = err instanceof Error ? err.message : String(err);
|
||||
console.warn('[me/delete] storage sweep failed:', err);
|
||||
}
|
||||
|
||||
auditLog({
|
||||
event: 'dsgvo.delete',
|
||||
actor_user_id: userId,
|
||||
target_user_id: userId,
|
||||
auth_mode: c.get('authMode'),
|
||||
ip: c.req.header('X-Forwarded-For') ?? null,
|
||||
user_agent: c.req.header('User-Agent') ?? null,
|
||||
result: storageOk ? 'success' : 'partial',
|
||||
detail: {
|
||||
decks: deletedDecks.length,
|
||||
import_jobs: deletedImports.length,
|
||||
media_files: deletedMediaFiles.length,
|
||||
storage_objects: storageObjectsDeleted,
|
||||
storage_ok: storageOk,
|
||||
storage_error: storageError,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({
|
||||
deleted: true,
|
||||
user_id: userId,
|
||||
storage_ok: storageOk,
|
||||
...(storageError ? { storage_error: storageError } : {}),
|
||||
counts: {
|
||||
decks: deletedDecks.length,
|
||||
import_jobs: deletedImports.length,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Hono } from 'hono';
|
|||
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||
import { mediaFiles, type MediaFileRow } from '../db/schema/index.ts';
|
||||
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||
import { rateLimit, userKey } from '../middleware/rate-limit.ts';
|
||||
import { ulid } from '../lib/ulid.ts';
|
||||
import { getStorage, type StorageService } from '../services/storage.ts';
|
||||
|
||||
|
|
@ -47,6 +48,10 @@ export function mediaRouter(deps: MediaDeps = {}): Hono<{ Variables: AuthVars }>
|
|||
const storageOf = () => deps.storage ?? getStorage();
|
||||
|
||||
r.use('*', authMiddleware);
|
||||
// 30/min per User — Upload ist teuer (Storage + DB-Insert), aber
|
||||
// ein Anki-Import kann viele Files in Folge schicken; das Limit
|
||||
// soll Bursts ermöglichen, aber Dauer-Spam stoppen.
|
||||
r.use('/upload', rateLimit({ scope: 'media.upload', windowMs: 60_000, max: 30, keyOf: userKey }));
|
||||
|
||||
/**
|
||||
* Multipart-Upload eines einzelnen Files. Bilder/Audio/Video; alles
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import manifest from '../../../../app-manifest.json' with { type: 'json' };
|
|||
|
||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||
import { rateLimit, ipKey } from '../middleware/rate-limit.ts';
|
||||
import { SHARE_HANDLERS, type ShareHandlerName } from '../share-handlers/index.ts';
|
||||
|
||||
export type ShareDeps = { db?: CardsDb };
|
||||
|
|
@ -18,6 +19,8 @@ export function shareRouter(deps: ShareDeps = {}): Hono<{ Variables: AuthVars }>
|
|||
const dbOf = () => deps.db ?? getDb();
|
||||
|
||||
r.use('*', authMiddleware);
|
||||
// 60/min per IP — Federation-Endpoint, soll für legitime App-Calls reichen.
|
||||
r.use('/receive', rateLimit({ scope: 'share.receive', windowMs: 60_000, max: 60, keyOf: ipKey }));
|
||||
|
||||
r.post('/receive', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
|
|
|
|||
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';
|
||||
}
|
||||
7
apps/api/vitest.config.ts
Normal file
7
apps/api/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue