diff --git a/cms/.env.example b/cms/.env.example index 30f6505..f8b8659 100644 --- a/cms/.env.example +++ b/cms/.env.example @@ -20,7 +20,13 @@ API_EXTERNAL_URL=http://localhost:8000 # unter `authors` steht. ADMIN_EMAILS=karim@gabrielevarano.ch -# ═══ Optional: Ports ═══ +# ═══ Optional: Ports & Binding ═══ +# Auf welcher Host-Adresse lauschen die veröffentlichten Ports? +# 127.0.0.1 (Standard) = nur lokal / hinter Reverse-Proxy mit TLS (empfohlen). +# 0.0.0.0 = direkt im LAN erreichbar (ohne Proxy). +# Bei 127.0.0.1 muss SITE_URL/API_EXTERNAL_URL über den Proxy laufen, sonst +# erreicht der Browser :8000/:8080 nicht. +BIND_ADDR=127.0.0.1 APP_PORT=8080 # CMS: Site + /admin + /_preview + /api KONG_HTTP_PORT=8000 # Supabase-API-Gateway KONG_HTTPS_PORT=8443 diff --git a/cms/README.md b/cms/README.md index a2a0caa..53248a6 100644 --- a/cms/README.md +++ b/cms/README.md @@ -72,8 +72,11 @@ Fragt interaktiv nur Storage/Bridge/IP ab (Enter = Default). Kein Token nötig. 2. `POSTGRES_PASSWORD` + `JWT_SECRET` setzen: je `openssl rand -hex 32` 3. Keys ableiten: `node scripts/generate-keys.mjs` → `ANON_KEY` + `SERVICE_ROLE_KEY` in `.env` 4. `SITE_URL` + `API_EXTERNAL_URL` auf die LAN-/Domain-Adresse setzen -5. `docker compose up -d --build` (Erststart: DB bootet + Schema/Migrations) -6. Login-User anlegen (Self-Signup ist aus): +5. `kong.yml`: Platzhalter `__CORS_ORIGIN__` durch `SITE_URL` (Browser-Origin) ersetzen +6. `BIND_ADDR` in `.env`: `127.0.0.1` hinter Reverse-Proxy (Standard), `0.0.0.0` für LAN-Direktzugriff +7. Repo dem Container-User (uid 1000) übereignen: `chown -R 1000:1000 ` +8. `docker compose up -d --build` (Erststart: DB bootet + Schema/Migrations) +9. Login-User anlegen (Self-Signup ist aus): ``` source .env curl -X POST "$API_EXTERNAL_URL/auth/v1/admin/users" \ @@ -89,6 +92,38 @@ Dann: Admin `…:8080/admin/` · Live `…:8080/` · Preview `…:8080/_preview/ `cd admin && npm install && npm run dev` (Vite-Devserver, proxyt `/api` + `/_preview` an den laufenden Container auf :8080). +## Sicherheit / Härtung + +Eingebaute Schutzmaßnahmen (Stand: Härtungs-Pass): + +- **Sicherheits-Header** auf allen Antworten (X-Frame-Options, nosniff, + Referrer-Policy, HSTS); Uploads unter `/images/*` mit strikter CSP + + `sandbox` → ein bösartiges SVG kann kein JavaScript im Origin ausführen. +- **Rate-Limit** auf `/api/auth/login` (10 Versuche/IP pro 5 Min) gegen Brute-Force. +- **Body-Limit** 256 KB auf JSON-`/api/*`, Bild-Upload max. 8 MB mit + Format-Verifikation (sharp-Metadaten bzw. SVG/GIF-Signatur). +- **Comment-Limits** (Body ≤ 10 000 Zeichen) gegen DB-Bloat. +- **Kein Info-Leak**: rohe DB-Fehler werden serverseitig geloggt, nach außen + nur generische Meldungen. +- **Non-root**: der CMS-Container läuft als `node` (uid 1000). +- **Port-Binding** über `BIND_ADDR` (Standard `127.0.0.1`), DB nur auf localhost. +- **CORS** am Kong-Gateway auf die eigene `SITE_URL`-Origin beschränkt (kein `*`). + +### Migration eines bestehenden Containers + +Bei `git pull` auf einer schon laufenden Instanz greifen drei Änderungen, die +sonst einen frischen Deploy voraussetzen — **vor** dem nächsten +`docker compose up -d --build` von Hand nachziehen: + +1. **Non-root:** `chown -R 1000:1000 ` — sonst kann Hugo `public/` + nicht mehr bauen (Permission denied). +2. **CORS:** `kong.yml` enthält jetzt `__CORS_ORIGIN__`; auf einem bereits + initialisierten Container ersetzt das Proxmox-Script den Platzhalter nicht. + Manuell auf die `SITE_URL` setzen, sonst werden alle Browser-API-Calls + (inkl. Login) per CORS geblockt. +3. **BIND_ADDR:** Key in `.env` ergänzen. Default `127.0.0.1` ist hinter einem + TLS-Proxy korrekt; für LAN-Direktzugriff `0.0.0.0` setzen. + ## API Alle `/api/*` (ausser `/health`) verlangen `Authorization: Bearer `. diff --git a/cms/api/Dockerfile b/cms/api/Dockerfile index 6eebaa1..ca8cbb6 100644 --- a/cms/api/Dockerfile +++ b/cms/api/Dockerfile @@ -40,5 +40,12 @@ COPY --from=admin /admin/dist ./admin-dist ENV NODE_ENV=production ENV ADMIN_DIR=/app/admin-dist + +# Als non-root laufen (das node-Image bringt den User `node`, uid/gid 1000 mit). +# /app gehört dem Build (root, read-only zur Laufzeit — reicht zum Servieren). +# Das gemountete Repo unter /site muss uid 1000 gehören (siehe Proxmox-Script: +# chown -R 1000:1000), damit Hugo dort public/ bauen und content/ schreiben kann. +USER node + EXPOSE 3000 CMD ["sh", "/app/entrypoint.sh"] diff --git a/cms/api/src/index.js b/cms/api/src/index.js index 73f5d4c..d570265 100644 --- a/cms/api/src/index.js +++ b/cms/api/src/index.js @@ -1,7 +1,10 @@ import { serve } from '@hono/node-server'; import { serveStatic } from '@hono/node-server/serve-static'; import { Hono } from 'hono'; +import { secureHeaders } from 'hono/secure-headers'; +import { bodyLimit } from 'hono/body-limit'; +import { rateLimit } from './ratelimit.js'; import content from './routes/content.js'; import preview from './routes/preview.js'; import publish from './routes/publish.js'; @@ -21,7 +24,31 @@ const PORT = Number(process.env.PORT || 3000); const app = new Hono(); +// --- Sicherheits-Header (auf allem) --- +// CSP bewusst zurückhaltend: Site + Admin-SPA + Dialog-Widget laufen same-origin. +app.use('*', secureHeaders({ + xFrameOptions: 'SAMEORIGIN', + xContentTypeOptions: 'nosniff', + referrerPolicy: 'strict-origin-when-cross-origin', + crossOriginOpenerPolicy: 'same-origin', + // HSTS nur sinnvoll hinter TLS-Proxy; schadet via HTTP nicht (Browser ignoriert). + strictTransportSecurity: 'max-age=31536000; includeSubDomains', +})); + +// Hochgeladene Bilder strikt isolieren: ein bösartiges SVG kann so kein +// JavaScript im Origin ausführen (sandbox + keine Skript-Quellen). +app.use('/images/*', secureHeaders({ + contentSecurityPolicy: { defaultSrc: ["'none'"], imgSrc: ["'self'"], styleSrc: ["'unsafe-inline'"], sandbox: [] }, + xContentTypeOptions: 'nosniff', +})); + // --- API --- +// Globales Limit gegen aufgeblähte JSON-Bodies (DoS / DB-Bloat). Der Upload-Pfad +// ist ausgenommen — der bringt sein eigenes, größeres Bild-Limit mit. +const jsonBodyLimit = bodyLimit({ maxSize: 256 * 1024, onError: (c) => c.json({ error: 'Anfrage zu groß' }, 413) }); +app.use('/api/*', (c, next) => + c.req.path.startsWith('/api/upload') ? next() : jsonBodyLimit(c, next)); + app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' })); // Öffentlich (ohne Login): Dialog lesen, Übersicht, Login fürs Dialog-Widget. app.get('/api/comments', listComments); @@ -29,7 +56,8 @@ app.get('/api/forums', listForums); app.get('/api/forums/:slug', showForum); app.get('/api/recent', recent); app.get('/api/thread', threadInfo); -app.post('/api/auth/login', login); +// Login gegen Brute-Force drosseln: max. 10 Versuche/IP pro 5 Minuten. +app.post('/api/auth/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), login); // Alles weitere unter /api/* braucht ein gültiges Supabase-Token. app.use('/api/*', requireAuth); app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') })); diff --git a/cms/api/src/ratelimit.js b/cms/api/src/ratelimit.js new file mode 100644 index 0000000..7fcf9fc --- /dev/null +++ b/cms/api/src/ratelimit.js @@ -0,0 +1,34 @@ +// Einfacher In-Memory-Rate-Limiter (ein Container, eine Instanz → genügt). +// Fixed-Window pro Schlüssel (Standard: Client-IP). Bei Überschreitung 429. +// Hinter einem Reverse-Proxy liefert X-Forwarded-For die echte IP. + +const buckets = new Map(); // key -> { count, reset } + +function clientIp(c) { + const xff = c.req.header('x-forwarded-for'); + if (xff) return xff.split(',')[0].trim(); + return c.req.header('x-real-ip') || 'unknown'; +} + +// max Anfragen je windowMs. keyFn erlaubt eigene Schlüssel (z.B. IP+E-Mail). +export function rateLimit({ max = 10, windowMs = 60_000, keyFn = clientIp } = {}) { + return async (c, next) => { + const key = keyFn(c); + const now = Date.now(); + let b = buckets.get(key); + if (!b || now > b.reset) { b = { count: 0, reset: now + windowMs }; buckets.set(key, b); } + b.count += 1; + if (b.count > max) { + const retry = Math.ceil((b.reset - now) / 1000); + c.header('Retry-After', String(retry)); + return c.json({ error: 'Zu viele Anfragen — bitte später erneut.' }, 429); + } + await next(); + }; +} + +// Speicher sauber halten: abgelaufene Buckets periodisch wegräumen. +setInterval(() => { + const now = Date.now(); + for (const [k, b] of buckets) if (now > b.reset) buckets.delete(k); +}, 5 * 60_000).unref?.(); diff --git a/cms/api/src/routes/comments.js b/cms/api/src/routes/comments.js index 99b610a..3306320 100644 --- a/cms/api/src/routes/comments.js +++ b/cms/api/src/routes/comments.js @@ -1,10 +1,13 @@ import { supabase, supabaseAuth } from '../supabase.js'; import { roleOf } from '../auth.js'; import { profileFor, threadLocked } from '../dialog-store.js'; +import { serverError } from '../util.js'; // Dialog: flache Wortmeldungen pro Thread (= Thread-Key), optionaler Bezug. const COLS = 'id,thread,parent_id,author_name,author_avatar,author_role,body,created_at,deleted'; +const MAX_BODY = 10_000; // Zeichen je Wortmeldung +const MAX_THREAD = 512; // Thread-Key-Länge // ÖFFENTLICH: Wortmeldungen eines Threads lesen. export async function listComments(c) { @@ -12,7 +15,7 @@ export async function listComments(c) { if (!thread) return c.json({ error: 'thread fehlt' }, 400); const { data, error } = await supabase .from('comments').select(COLS).eq('thread', thread).order('created_at', { ascending: true }); - if (error) return c.json({ error: error.message }, 500); + if (error) return serverError(c, 'listComments', error); const out = (data || []).map((r) => (r.deleted ? { ...r, body: '[gelöscht]', author_avatar: null } : r)); return c.json(out); } @@ -23,6 +26,8 @@ export async function createComment(c) { const email = c.get('email'); const { thread, body, parent_id } = await c.req.json(); if (!thread || !body || !body.trim()) return c.json({ error: 'thread und Text nötig' }, 400); + if (typeof thread !== 'string' || thread.length > MAX_THREAD) return c.json({ error: 'Ungültiger Thread' }, 400); + if (typeof body !== 'string' || body.length > MAX_BODY) return c.json({ error: `Text zu lang (max. ${MAX_BODY} Zeichen)` }, 400); if (await threadLocked(thread)) return c.json({ error: 'Thread ist gesperrt' }, 403); const prof = await profileFor(email); @@ -36,7 +41,7 @@ export async function createComment(c) { body: body.trim(), }; const { data, error } = await supabase.from('comments').insert(row).select(COLS).single(); - if (error) return c.json({ error: error.message }, 400); + if (error) return serverError(c, 'createComment', error, 400); return c.json(data, 201); } @@ -49,7 +54,7 @@ export async function deleteComment(c) { if (e1 || !row) return c.json({ error: 'Nicht gefunden' }, 404); if (!canModerate && row.user_id !== user.id) return c.json({ error: 'Kein Recht' }, 403); const { error } = await supabase.from('comments').update({ deleted: true }).eq('id', id); - if (error) return c.json({ error: error.message }, 400); + if (error) return serverError(c, 'deleteComment', error, 400); return c.json({ ok: true }); } diff --git a/cms/api/src/routes/dialog.js b/cms/api/src/routes/dialog.js index 2f29315..a10624e 100644 --- a/cms/api/src/routes/dialog.js +++ b/cms/api/src/routes/dialog.js @@ -1,6 +1,7 @@ import { Hono } from 'hono'; import { supabase } from '../supabase.js'; import { requireAdmin, requireModerator } from '../auth.js'; +import { serverError } from '../util.js'; import { forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta, } from '../dialog-store.js'; @@ -56,7 +57,7 @@ mod.post('/thread-lock', async (c) => { const { key, locked } = await c.req.json(); if (!key) return c.json({ error: 'key nötig' }, 400); const { error } = await supabase.from('threads').update({ locked: !!locked }).eq('key', key); - if (error) return c.json({ error: error.message }, 400); + if (error) return serverError(c, 'dialog', error, 400); return c.json({ ok: true }); }); // Thread ausblenden (löschen). @@ -64,7 +65,7 @@ mod.post('/thread-delete', async (c) => { const { key } = await c.req.json(); if (!key) return c.json({ error: 'key nötig' }, 400); const { error } = await supabase.from('threads').update({ deleted: true }).eq('key', key); - if (error) return c.json({ error: error.message }, 400); + if (error) return serverError(c, 'dialog', error, 400); return c.json({ ok: true }); }); @@ -73,7 +74,7 @@ export const adminForums = new Hono(); adminForums.use('*', requireAdmin); adminForums.get('/', async (c) => { const { data, error } = await supabase.from('forums').select('*').order('sort'); - if (error) return c.json({ error: error.message }, 500); + if (error) return serverError(c, 'dialog', error, 500); return c.json(data || []); }); adminForums.post('/', async (c) => { @@ -82,7 +83,7 @@ adminForums.post('/', async (c) => { const row = { slug: String(slug).trim(), name: String(name).trim(), description: description || '', color: color || null, sort: Number(sort) || 0 }; const { data, error } = await supabase.from('forums').insert(row).select('*').single(); - if (error) return c.json({ error: error.message }, 400); + if (error) return serverError(c, 'dialog', error, 400); return c.json(data, 201); }); adminForums.put('/:id', async (c) => { @@ -90,7 +91,7 @@ adminForums.put('/:id', async (c) => { const allowed = {}; for (const k of ['name', 'description', 'color', 'sort', 'slug']) if (k in patch) allowed[k] = patch[k]; const { data, error } = await supabase.from('forums').update(allowed).eq('id', c.req.param('id')).select('*').single(); - if (error) return c.json({ error: error.message }, 400); + if (error) return serverError(c, 'dialog', error, 400); return c.json(data); }); adminForums.delete('/:id', async (c) => { @@ -98,6 +99,6 @@ adminForums.delete('/:id', async (c) => { const { data: f } = await supabase.from('forums').select('kind').eq('id', id).single(); if (f?.kind === 'library') return c.json({ error: 'Beiträge-Kategorie kann nicht gelöscht werden' }, 400); const { error } = await supabase.from('forums').delete().eq('id', id); - if (error) return c.json({ error: error.message }, 400); + if (error) return serverError(c, 'dialog', error, 400); return c.json({ ok: true }); }); diff --git a/cms/api/src/routes/profile.js b/cms/api/src/routes/profile.js index 114528b..425cf87 100644 --- a/cms/api/src/routes/profile.js +++ b/cms/api/src/routes/profile.js @@ -19,6 +19,20 @@ function slugify(s) { } async function exists(p) { try { await stat(p); return true; } catch { return false; } } +// Hugo-Build koaleszieren: nie zwei parallel, und schnelle Folge-Speicherungen +// lösen nur EINEN nachgelagerten Build aus (verhindert Build-Sturm/DoS). +let building = false, rerun = false; +async function rebuildSite() { + if (building) { rerun = true; return; } + building = true; + try { + do { + rerun = false; + await hugoBuild({ dest: 'public', drafts: false }).catch((e) => console.error('[profile] build:', e?.message || e)); + } while (rerun); + } finally { building = false; } +} + const profile = new Hono(); profile.get('/', async (c) => { @@ -46,8 +60,8 @@ profile.put('/', async (c) => { } const page = matter.stringify(bio || '', { title: name, avatar: avatar || '' }); await writeFile(path.join(AUTHORS_DIR, `${slug}.md`), page, 'utf8'); - // Live bauen, damit die Seite + Byline-Links sofort funktionieren. - await hugoBuild({ dest: 'public', drafts: false }).catch(() => {}); + // Live bauen (koalesziert), damit die Seite + Byline-Links sofort wirken. + await rebuildSite(); } return c.json({ ok: true, slug }); diff --git a/cms/api/src/routes/upload.js b/cms/api/src/routes/upload.js index 6f7cb7a..69796ed 100644 --- a/cms/api/src/routes/upload.js +++ b/cms/api/src/routes/upload.js @@ -6,8 +6,14 @@ import sharp from 'sharp'; // Bild-Upload → static/images/. Raster-Bilder werden zu WebP konvertiert // (kleiner, web-optimiert), auf max. 2000px begrenzt, EXIF-Rotation korrigiert. // SVG/GIF bleiben unangetastet (Vektor/Animation erhalten). +// +// Sicherheit: hartes Größenlimit (DoS / Decompression-Bombs), Raster wird über +// sharp-Metadaten als echtes Bild verifiziert, SVG nur wenn es wie SVG aussieht. +// Hochgeladene Dateien werden zudem mit strikter CSP (sandbox) ausgeliefert +// (siehe index.js, /images/*) → ein bösartiges SVG kann kein JS im Origin starten. const SITE_DIR = process.env.SITE_DIR || '/site'; -const PASSTHROUGH = new Set(['.svg', '.gif']); +const MAX_UPLOAD = 8 * 1024 * 1024; // 8 MB Rohdatei +const ALLOWED_RASTER = new Set(['jpeg', 'png', 'webp', 'avif', 'tiff']); const upload = new Hono(); @@ -15,19 +21,42 @@ upload.post('/', async (c) => { const body = await c.req.parseBody(); const file = body['file']; if (!file || typeof file === 'string') return c.json({ error: 'Keine Datei' }, 400); + if (typeof file.size === 'number' && file.size > MAX_UPLOAD) { + return c.json({ error: 'Datei zu groß (max. 8 MB)' }, 413); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + if (buffer.length > MAX_UPLOAD) return c.json({ error: 'Datei zu groß (max. 8 MB)' }, 413); + if (!buffer.length) return c.json({ error: 'Leere Datei' }, 400); const dir = path.join(SITE_DIR, 'static', 'images'); await mkdir(dir, { recursive: true }); - const buffer = Buffer.from(await file.arrayBuffer()); const ext = path.extname(file.name || '').toLowerCase(); const base = `${safeBase(file.name)}-${uid()}`; let outName, outBuf; - if (PASSTHROUGH.has(ext)) { - outName = `${base}${ext}`; + if (ext === '.svg') { + // Muss wie SVG aussehen (Magie/Marker), sonst ablehnen. + const head = buffer.subarray(0, 512).toString('utf8').trimStart().toLowerCase(); + if (!head.startsWith('