From 1e70ab15e9c47e06d8ca68e3cab04d9a5260628e Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 14 May 2026 22:55:23 +0200 Subject: [PATCH] security: fix K1-K3 critical + H1/H2/H5 + M1/M4/M5/M6/N2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K1: Asset-Token via Authorization-Header (nicht URL/Query → nicht in Logs) + UUID-Format-Whitelist gegen Path-Traversal / SSRF K2: /profile erfordert kurzlebiges Registration-Token (10 Min, signiert) statt ungeprüfter userId aus dem Body K3: PATCH /pair/:pairId/points prüft Ownership via Directus bevor Update H1: In-Memory Rate Limiting (Login/Register: 10/15min, Assets: 60/min) H2: Server startet nicht ohne CORS_ORIGIN (kein '*'-Fallback) H5: lang-Parameter Whitelist in content + UUID-Validierung in progress M1: points_earned, card_type, result server-seitig validiert (0-100, Enums) M4: Authorization-Header in Logs geschwärzt M5: Passwort-Länge server-seitig geprüft M6: Startup-Check für alle kritischen Env-Vars N2: pairId-UUID-Format erzwungen Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 47 ++++++++++++++-- src/lib/directus.ts | 36 ++++++++++-- src/lib/rateLimit.ts | 37 +++++++++++++ src/routes/auth.ts | 122 +++++++++++++++++++++-------------------- src/routes/content.ts | 98 ++++++++++++++------------------- src/routes/progress.ts | 107 ++++++++++++++++++------------------ 6 files changed, 269 insertions(+), 178 deletions(-) create mode 100644 src/lib/rateLimit.ts diff --git a/src/index.ts b/src/index.ts index 2e7330f..dabb40b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,18 +6,55 @@ import { logger } from 'hono/logger' import authRoutes from './routes/auth' import contentRoutes from './routes/content' import progressRoutes from './routes/progress' +import { rateLimit } from './lib/rateLimit' + +// ── Startup: kritische Env-Vars prüfen ─────────────────────────────────────── +const REQUIRED_ENV = ['JWT_SECRET', 'DIRECTUS_URL', 'DIRECTUS_ADMIN_TOKEN', 'CORS_ORIGIN'] +for (const key of REQUIRED_ENV) { + if (!process.env[key]) { + console.error(`FATAL: Fehlende Umgebungsvariable: ${key}`) + process.exit(1) + } +} const app = new Hono() -app.use('*', logger()) -app.use('*', cors({ - origin: process.env.CORS_ORIGIN || '*', - allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], - allowHeaders: ['Content-Type', 'Authorization'], +// ── Logger (Authorization-Header geschwärzt) ───────────────────────────────── +app.use('*', logger((str, ...rest) => { + const sanitized = str.replace(/Authorization: Bearer [^\s"]+/gi, 'Authorization: Bearer [REDACTED]') + console.log(sanitized, ...rest) })) +// ── CORS (kein unsicherer Fallback) ────────────────────────────────────────── +app.use('*', cors({ + origin: process.env.CORS_ORIGIN!, + allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization', 'X-Registration-Token'], + credentials: false, +})) + +// ── Security-Header ─────────────────────────────────────────────────────────── +app.use('*', async (c, next) => { + await next() + c.header('X-Content-Type-Options', 'nosniff') + c.header('X-Frame-Options', 'DENY') + c.header('Referrer-Policy', 'no-referrer') +}) + +// ── Rate Limits ─────────────────────────────────────────────────────────────── +// Auth-Endpoints: 10 Requests / 15 Minuten +app.use('/languparent/auth/login', rateLimit(10, 15 * 60 * 1000)) +app.use('/languparent/auth/register', rateLimit(10, 15 * 60 * 1000)) +app.use('/languparent/auth/profile', rateLimit(10, 15 * 60 * 1000)) +// Username-Check: 30 Requests / Minute +app.use('/languparent/auth/check-username', rateLimit(30, 60 * 1000)) +// Assets: 60 / Minute (Bilder für Learning-Cards) +app.use('/languparent/assets/*', rateLimit(60, 60 * 1000)) + +// ── Health ──────────────────────────────────────────────────────────────────── app.get('/health', (c) => c.json({ ok: true, service: 'hejyou-api' })) +// ── Routes ──────────────────────────────────────────────────────────────────── app.route('/languparent/auth', authRoutes) app.route('/languparent', contentRoutes) app.route('/languparent', progressRoutes) diff --git a/src/lib/directus.ts b/src/lib/directus.ts index 35a2152..41bcc8e 100644 --- a/src/lib/directus.ts +++ b/src/lib/directus.ts @@ -140,13 +140,31 @@ export async function dGetActivePair(profileId: string): Promise { return data.data[0] || null } -export async function dUpdatePairPoints(pairId: string, points: number): Promise { +// K-3 Fix: Ownership vor Update prüfen +export async function dUpdatePairPoints( + pairId: string, + points: number, + ownerProfileId: string, +): Promise<'ok' | 'not_found' | 'forbidden'> { + // 1. Ownership prüfen + const checkRes = await fetch( + `${BASE}/items/learning_pairs/${pairId}?fields=user`, + { headers: adminHeaders() }, + ) + if (!checkRes.ok) return 'not_found' + const checkData = await checkRes.json() as { data: { user: string } | null } + if (!checkData.data) return 'not_found' + if (checkData.data.user !== ownerProfileId) return 'forbidden' + + // 2. Wert in sinnvollem Bereich halten + const clamped = Math.max(0, Math.min(points, 10_000_000)) + const res = await fetch(`${BASE}/items/learning_pairs/${pairId}`, { method: 'PATCH', headers: adminHeaders(), - body: JSON.stringify({ points }), + body: JSON.stringify({ points: clamped }), }) - return res.ok + return res.ok ? 'ok' : 'not_found' } // ── Words ───────────────────────────────────────────────────────────────────── @@ -290,10 +308,18 @@ export async function dGetQAPairsAtLevel(level: number, langSuffix = 'de'): Prom // ── Assets ──────────────────────────────────────────────────────────────────── -export function dAssetUrl(fileId: string): string { - return `${BASE}/assets/${fileId}?access_token=${encodeURIComponent(ADMIN_TOKEN)}` +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +// K-1 Fix: Token via Authorization-Header (nicht Query-Param → taucht nicht in Logs auf) +export async function fetchAsset(fileId: string): Promise { + if (!UUID_RE.test(fileId)) throw new Error('Invalid fileId format') + return fetch(`${BASE}/assets/${fileId}`, { + headers: { 'Authorization': `Bearer ${ADMIN_TOKEN}` }, + }) } +export { UUID_RE } + // ── Profile Data ────────────────────────────────────────────────────────────── export async function dGetProfileData(userId: string): Promise { diff --git a/src/lib/rateLimit.ts b/src/lib/rateLimit.ts new file mode 100644 index 0000000..89accf7 --- /dev/null +++ b/src/lib/rateLimit.ts @@ -0,0 +1,37 @@ +import type { Context, Next } from 'hono' + +interface Entry { count: number; resetAt: number } +const store = new Map() + +// Stale entries bereinigen +setInterval(() => { + const now = Date.now() + for (const [k, e] of store) if (now > e.resetAt) store.delete(k) +}, 10 * 60 * 1000) + +function clientIp(c: Context): string { + return ( + c.req.header('CF-Connecting-IP') || + c.req.header('X-Forwarded-For')?.split(',')[0].trim() || + 'unknown' + ) +} + +export function rateLimit(maxRequests: number, windowMs: number) { + return async (c: Context, next: Next) => { + const key = `${clientIp(c)}:${c.req.path}` + const now = Date.now() + const entry = store.get(key) + + if (!entry || now > entry.resetAt) { + store.set(key, { count: 1, resetAt: now + windowMs }) + return next() + } + if (entry.count >= maxRequests) { + c.header('Retry-After', String(Math.ceil((entry.resetAt - now) / 1000))) + return c.json({ error: 'Too many requests. Please try again later.' }, 429) + } + entry.count++ + return next() + } +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 208d1a7..a723ef6 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono' -import { sign } from 'hono/jwt' +import { sign, verify } from 'hono/jwt' import { dLogin, dRegister, @@ -11,54 +11,72 @@ import { import { requireAuth } from '../middleware/auth' import type { JwtPayload } from '../middleware/auth' -const auth = new Hono() +const JWT_SECRET = () => process.env.JWT_SECRET! +const JWT_EXPIRY_SECONDS = 60 * 60 * 24 * 30 // 30 Tage +const REG_TOKEN_SECONDS = 60 * 10 // 10 Minuten -const JWT_EXPIRY_SECONDS = 60 * 60 * 24 * 30 // 30 days - -async function issueJwt(sub: string, username: string): Promise<{ token: string; expiresIn: number }> { - const secret = process.env.JWT_SECRET! - const exp = Math.floor(Date.now() / 1000) + JWT_EXPIRY_SECONDS - const payload: JwtPayload = { sub, username, exp } - const token = await sign(payload, secret) +async function issueJwt(sub: string, username: string) { + const exp = Math.floor(Date.now() / 1000) + JWT_EXPIRY_SECONDS + const token = await sign({ sub, username, exp } as JwtPayload, JWT_SECRET()) return { token, expiresIn: JWT_EXPIRY_SECONDS } } -// POST /register +const auth = new Hono() + +// ── POST /register ──────────────────────────────────────────────────────────── auth.post('/register', async (c) => { let body: { email?: string; password?: string } - try { - body = await c.req.json() - } catch { - return c.json({ error: 'Invalid JSON body' }, 400) - } + try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const { email, password } = body - if (!email || !password) { - return c.json({ error: 'email and password are required' }, 400) - } + if (!email || !password) return c.json({ error: 'email und password erforderlich' }, 400) + // M-5: Passwort-Validierung server-seitig + if (password.length < 8) return c.json({ error: 'Passwort muss mindestens 8 Zeichen haben' }, 400) + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: 'Ungültige E-Mail-Adresse' }, 400) try { await dRegister(email, password) const { access_token } = await dLogin(email, password) const user = await dGetUserByToken(access_token) - return c.json({ userId: user.id }, 201) + + // K-2: Kurzlebiges Registration-Token (10 Min) statt userId im Klartext + const exp = Math.floor(Date.now() / 1000) + REG_TOKEN_SECONDS + const registrationToken = await sign( + { sub: user.id, purpose: 'profile_setup', exp }, + JWT_SECRET(), + ) + return c.json({ registrationToken }, 201) } catch (err: any) { - return c.json({ error: err.message || 'Registration failed' }, 400) + return c.json({ error: err.message || 'Registrierung fehlgeschlagen' }, 400) } }) -// POST /profile +// ── POST /profile ───────────────────────────────────────────────────────────── +// K-2: userId kommt aus dem signierten Registration-Token (nicht aus dem Body) auth.post('/profile', async (c) => { - let body: { userId?: string; username?: string; nativeLang?: string; targetLang?: string } - try { - body = await c.req.json() - } catch { - return c.json({ error: 'Invalid JSON body' }, 400) - } + const regTokenHeader = c.req.header('X-Registration-Token') + if (!regTokenHeader) return c.json({ error: 'X-Registration-Token fehlt' }, 401) - const { userId, username, nativeLang, targetLang } = body - if (!userId || !username || !nativeLang || !targetLang) { - return c.json({ error: 'userId, username, nativeLang, and targetLang are required' }, 400) + let regPayload: any + try { + regPayload = await verify(regTokenHeader, JWT_SECRET(), 'HS256') + } catch { + return c.json({ error: 'Ungültiges oder abgelaufenes Registration-Token' }, 401) + } + if (regPayload.purpose !== 'profile_setup') { + return c.json({ error: 'Falsches Token-Purpose' }, 401) + } + const userId = regPayload.sub as string + + let body: { username?: string; nativeLang?: string; targetLang?: string } + try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } + + const { username, nativeLang, targetLang } = body + if (!username || !nativeLang || !targetLang) { + return c.json({ error: 'username, nativeLang und targetLang erforderlich' }, 400) + } + if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) { + return c.json({ error: 'Username: 3–20 Zeichen, nur Buchstaben, Zahlen und _' }, 400) } try { @@ -66,69 +84,57 @@ auth.post('/profile', async (c) => { const { token, expiresIn } = await issueJwt(userId, profileId) return c.json({ token, expiresIn }, 201) } catch (err: any) { - return c.json({ error: err.message || 'Profile creation failed' }, 400) + return c.json({ error: err.message || 'Profilerstellung fehlgeschlagen' }, 400) } }) -// POST /login +// ── POST /login ─────────────────────────────────────────────────────────────── auth.post('/login', async (c) => { let body: { email?: string; password?: string } - try { - body = await c.req.json() - } catch { - return c.json({ error: 'Invalid JSON body' }, 400) - } + try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const { email, password } = body - if (!email || !password) { - return c.json({ error: 'email and password are required' }, 400) - } + if (!email || !password) return c.json({ error: 'email und password erforderlich' }, 400) try { const { access_token } = await dLogin(email, password) const user = await dGetUserByToken(access_token) - if (user.username === null) { - return c.json( - { error: 'Profile not set up', userId: user.id, needsProfile: true }, - 403, - ) + if (!user.username) { + return c.json({ error: 'Profil noch nicht eingerichtet', needsProfile: true, userId: user.id }, 403) } - // user.username holds the profileId stored on the Directus user record const { token, expiresIn } = await issueJwt(user.id, user.username) return c.json({ token, expiresIn }) } catch (err: any) { - return c.json({ error: err.message || 'Login failed' }, 401) + // Gleicher Fehlertext für falsche E-Mail und falsches Passwort (User-Enumeration verhindern) + return c.json({ error: 'E-Mail oder Passwort falsch' }, 401) } }) -// GET /me [requireAuth] +// ── GET /me ─────────────────────────────────────────────────────────────────── auth.get('/me', requireAuth, async (c) => { const payload = c.get('jwtPayload') as JwtPayload try { const profile = await dGetProfileData(payload.sub) - if (!profile) { - return c.json({ error: 'Profile not found' }, 404) - } + if (!profile) return c.json({ error: 'Profil nicht gefunden' }, 404) return c.json(profile) } catch (err: any) { - return c.json({ error: err.message || 'Failed to fetch profile' }, 500) + return c.json({ error: 'Profil konnte nicht geladen werden' }, 500) } }) -// GET /check-username?username=xxx [public] +// ── GET /check-username ─────────────────────────────────────────────────────── auth.get('/check-username', async (c) => { const username = c.req.query('username') - if (!username) { - return c.json({ error: 'username query parameter is required' }, 400) - } + if (!username) return c.json({ error: 'username Parameter fehlt' }, 400) + if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) return c.json({ available: false }) // ungültiges Format = nie verfügbar try { const available = await dCheckUsername(username) return c.json({ available }) - } catch (err: any) { - return c.json({ error: err.message || 'Failed to check username' }, 500) + } catch { + return c.json({ error: 'Username-Check fehlgeschlagen' }, 500) } }) diff --git a/src/routes/content.ts b/src/routes/content.ts index 7650a78..288f24d 100644 --- a/src/routes/content.ts +++ b/src/routes/content.ts @@ -5,103 +5,89 @@ import { dGetWords, dGetQuestions, dGetQAPairsAtLevel, - dAssetUrl, + fetchAsset, + UUID_RE, } from '../lib/directus' import { requireAuth } from '../middleware/auth' import type { JwtPayload } from '../middleware/auth' +// H-5: Erlaubte Sprach-Suffixes +const ALLOWED_LANGS = new Set(['de', 'en', 'se']) + const content = new Hono() -// GET /languages (public) +// ── GET /languages (public) ────────────────────────────────────────────────── content.get('/languages', async (c) => { try { - const languages = await dGetLanguages() - return c.json(languages) - } catch (err: any) { - return c.json({ error: err.message || 'Failed to fetch languages' }, 500) + return c.json(await dGetLanguages()) + } catch { + return c.json({ error: 'Sprachen konnten nicht geladen werden' }, 500) } }) -// GET /pair [auth] +// ── GET /pair [auth] ───────────────────────────────────────────────────────── content.get('/pair', requireAuth, async (c) => { - const payload = c.get('jwtPayload') as JwtPayload + const { username } = c.get('jwtPayload') as JwtPayload try { - const pair = await dGetActivePair(payload.username) - if (!pair) { - return c.json({ error: 'No active learning pair found' }, 404) - } + const pair = await dGetActivePair(username) + if (!pair) return c.json({ error: 'Kein aktives Sprachpaar gefunden' }, 404) return c.json(pair) - } catch (err: any) { - return c.json({ error: err.message || 'Failed to fetch active pair' }, 500) + } catch { + return c.json({ error: 'Sprachpaar konnte nicht geladen werden' }, 500) } }) -// GET /words [auth] +// ── GET /words [auth] ──────────────────────────────────────────────────────── content.get('/words', requireAuth, async (c) => { - try { - const words = await dGetWords() - return c.json(words) - } catch (err: any) { - return c.json({ error: err.message || 'Failed to fetch words' }, 500) - } + try { return c.json(await dGetWords()) } + catch { return c.json({ error: 'Wörter konnten nicht geladen werden' }, 500) } }) -// GET /questions [auth] +// ── GET /questions [auth] ──────────────────────────────────────────────────── content.get('/questions', requireAuth, async (c) => { - try { - const questions = await dGetQuestions() - return c.json(questions) - } catch (err: any) { - return c.json({ error: err.message || 'Failed to fetch questions' }, 500) - } + try { return c.json(await dGetQuestions()) } + catch { return c.json({ error: 'Fragen konnten nicht geladen werden' }, 500) } }) -// GET /qa-pairs [auth] ?level=1&lang=de +// ── GET /qa-pairs?level=1&lang=de [auth] ──────────────────────────────────── content.get('/qa-pairs', requireAuth, async (c) => { const levelParam = c.req.query('level') - const lang = c.req.query('lang') || 'de' - - if (!levelParam) { - return c.json({ error: 'level query parameter is required' }, 400) - } + const lang = c.req.query('lang') || 'de' + if (!levelParam) return c.json({ error: 'level Parameter fehlt' }, 400) const level = parseInt(levelParam, 10) - if (isNaN(level)) { - return c.json({ error: 'level must be a number' }, 400) - } + if (isNaN(level) || level < 1 || level > 100) return c.json({ error: 'level muss eine Zahl zwischen 1 und 100 sein' }, 400) - try { - const pairs = await dGetQAPairsAtLevel(level, lang) - return c.json(pairs) - } catch (err: any) { - return c.json({ error: err.message || 'Failed to fetch QA pairs' }, 500) - } + // H-5: Sprach-Whitelist + if (!ALLOWED_LANGS.has(lang)) return c.json({ error: 'Ungültiger lang Parameter' }, 400) + + try { return c.json(await dGetQAPairsAtLevel(level, lang)) } + catch { return c.json({ error: 'QA-Pairs konnten nicht geladen werden' }, 500) } }) -// GET /assets/:fileId — public (images are non-sensitive learning content) +// ── GET /assets/:fileId (public, rate-limited in index.ts) ────────────────── +// K-1 Fix: fetchAsset nutzt Authorization-Header (Token nicht in URL/Logs) content.get('/assets/:fileId', async (c) => { const fileId = c.req.param('fileId') - if (!fileId) return c.json({ error: 'fileId required' }, 400) - const assetUrl = dAssetUrl(fileId) + + // K-1: UUID-Format erzwingen (verhindert Path-Traversal / SSRF) + if (!fileId || !UUID_RE.test(fileId)) { + return c.json({ error: 'Ungültiges fileId-Format' }, 400) + } try { - const upstream = await fetch(assetUrl) - if (!upstream.ok) { - return c.json({ error: 'Asset not found' }, upstream.status as any) - } + const upstream = await fetchAsset(fileId) + if (!upstream.ok) return c.json({ error: 'Asset nicht gefunden' }, 404) const contentType = upstream.headers.get('Content-Type') || 'application/octet-stream' - const body = upstream.body - - return new Response(body, { - status: 200, + return new Response(upstream.body, { headers: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=86400', }, }) - } catch (err: any) { - return c.json({ error: err.message || 'Failed to fetch asset' }, 500) + } catch { + return c.json({ error: 'Asset konnte nicht geladen werden' }, 500) } }) diff --git a/src/routes/progress.ts b/src/routes/progress.ts index ff933c5..06bb07d 100644 --- a/src/routes/progress.ts +++ b/src/routes/progress.ts @@ -1,59 +1,61 @@ import { Hono } from 'hono' -import { dGetProgress, dSaveProgress, dUpdatePairPoints } from '../lib/directus' +import { dGetProgress, dSaveProgress, dUpdatePairPoints, UUID_RE } from '../lib/directus' import { requireAuth } from '../middleware/auth' import type { JwtPayload } from '../middleware/auth' -const progress = new Hono() +// M-1: Erlaubte Enum-Werte +const ALLOWED_CARD_TYPES = new Set(['write', 'speak', 'sentence_fill']) +const ALLOWED_RESULTS = new Set(['correct', 'wrong', 'skipped']) -// All routes require auth +const progress = new Hono() progress.use('*', requireAuth) -// GET /progress ?lang=de +// ── GET /progress?lang= [auth] ───────────────────────────────────────── progress.get('/progress', async (c) => { - const payload = c.get('jwtPayload') as JwtPayload + const { username } = c.get('jwtPayload') as JwtPayload const lang = c.req.query('lang') + // lang muss entweder fehlen oder eine gültige UUID sein (Directus language_option ID) + if (lang && !UUID_RE.test(lang)) return c.json({ error: 'Ungültiger lang Parameter' }, 400) + try { - const data = await dGetProgress(payload.username, lang) - return c.json(data) - } catch (err: any) { - return c.json({ error: err.message || 'Failed to fetch progress' }, 500) + return c.json(await dGetProgress(username, lang)) + } catch { + return c.json({ error: 'Fortschritt konnte nicht geladen werden' }, 500) } }) -// POST /progress +// ── POST /progress [auth] ──────────────────────────────────────────────────── progress.post('/progress', async (c) => { - const payload = c.get('jwtPayload') as JwtPayload + const { username } = c.get('jwtPayload') as JwtPayload - let body: { - word?: string | null - question?: string | null - card_type?: string - result?: string - points_earned?: number - language_from?: string - language_to?: string - } - try { - body = await c.req.json() - } catch { - return c.json({ error: 'Invalid JSON body' }, 400) - } + let body: any + try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } const { word, question, card_type, result, points_earned, language_from, language_to } = body - if (!card_type || !result || points_earned === undefined || !language_from || !language_to) { - return c.json( - { error: 'card_type, result, points_earned, language_from, and language_to are required' }, - 400, - ) + // M-1: Server-seitige Validierung aller Felder + if (!card_type || !ALLOWED_CARD_TYPES.has(card_type)) { + return c.json({ error: `card_type muss einer von: ${[...ALLOWED_CARD_TYPES].join(', ')} sein` }, 400) } + if (!result || !ALLOWED_RESULTS.has(result)) { + return c.json({ error: `result muss einer von: ${[...ALLOWED_RESULTS].join(', ')} sein` }, 400) + } + if (typeof points_earned !== 'number' || !Number.isInteger(points_earned) || + points_earned < 0 || points_earned > 100) { + return c.json({ error: 'points_earned muss eine ganze Zahl zwischen 0 und 100 sein' }, 400) + } + if (!language_from || !UUID_RE.test(language_from)) return c.json({ error: 'Ungültige language_from' }, 400) + if (!language_to || !UUID_RE.test(language_to)) return c.json({ error: 'Ungültige language_to' }, 400) + if (word && !UUID_RE.test(word)) return c.json({ error: 'Ungültige word-UUID' }, 400) + if (question && !UUID_RE.test(question)) return c.json({ error: 'Ungültige question-UUID' }, 400) try { + // user kommt immer aus dem JWT — nie aus dem Body (verhindert User-Spoofing) const saved = await dSaveProgress({ - user: payload.username, - word: word ?? null, - question: question ?? null, + user: username, + word: word || null, + question: question || null, card_type, result, points_earned, @@ -61,35 +63,32 @@ progress.post('/progress', async (c) => { language_to, }) return c.json(saved, 201) - } catch (err: any) { - return c.json({ error: err.message || 'Failed to save progress' }, 500) + } catch { + return c.json({ error: 'Fortschritt konnte nicht gespeichert werden' }, 500) } }) -// PATCH /pair/:pairId/points { points } +// ── PATCH /pair/:pairId/points [auth] ──────────────────────────────────────── progress.patch('/pair/:pairId/points', async (c) => { + const { username } = c.get('jwtPayload') as JwtPayload const pairId = c.req.param('pairId') - let body: { points?: number } - try { - body = await c.req.json() - } catch { - return c.json({ error: 'Invalid JSON body' }, 400) + // N-2: UUID-Format erzwingen + if (!pairId || !UUID_RE.test(pairId)) return c.json({ error: 'Ungültiges pairId-Format' }, 400) + + let body: any + try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) } + + const { points } = body + if (typeof points !== 'number' || !Number.isInteger(points) || points < 0) { + return c.json({ error: 'points muss eine nicht-negative ganze Zahl sein' }, 400) } - if (body.points === undefined || typeof body.points !== 'number') { - return c.json({ error: 'points (number) is required' }, 400) - } - - try { - const ok = await dUpdatePairPoints(pairId, body.points) - if (!ok) { - return c.json({ error: 'Failed to update pair points' }, 500) - } - return c.json({ ok: true }) - } catch (err: any) { - return c.json({ error: err.message || 'Failed to update pair points' }, 500) - } + // K-3: Ownership-Check passiert in dUpdatePairPoints + const outcome = await dUpdatePairPoints(pairId, points, username) + if (outcome === 'forbidden') return c.json({ error: 'Zugriff verweigert' }, 403) + if (outcome === 'not_found') return c.json({ error: 'Sprachpaar nicht gefunden' }, 404) + return c.json({ ok: true }) }) export default progress