security: Härtung der CMS-API + Deployment
App-Level: - Security-Header (secureHeaders) global; /images/* mit strikter CSP+sandbox → bösartiges SVG kann kein JS im Origin ausführen - Body-Limit 256 KB auf /api/*; Login-Rate-Limit (10/5min) gegen Brute-Force - Upload: 8-MB-Limit + Format-Verifikation (sharp-Metadaten, SVG/GIF-Signatur) - Comment-Längenlimit (10k) gegen DB-Bloat - DB-Fehler nicht mehr roh ausliefern (serverError-Helper) - Profil-PUT koalesziert Hugo-Builds (kein Build-Sturm) Infra: - Container läuft non-root (USER node, uid 1000) + Proxmox-Repo-chown - Ports binden per Default auf 127.0.0.1 (BIND_ADDR-Escape-Hatch) - Kong-CORS auf SITE_URL beschränkt statt "*" - README: Härtungs- + Migrationshinweise Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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('<?xml') && !head.startsWith('<svg')) {
|
||||
return c.json({ error: 'Keine gültige SVG-Datei' }, 400);
|
||||
}
|
||||
outName = `${base}.svg`;
|
||||
outBuf = buffer;
|
||||
} else if (ext === '.gif') {
|
||||
// GIF-Magie prüfen (kann kein Skript ausführen → Passthrough ok).
|
||||
const sig = buffer.subarray(0, 6).toString('latin1');
|
||||
if (sig !== 'GIF87a' && sig !== 'GIF89a') return c.json({ error: 'Keine gültige GIF-Datei' }, 400);
|
||||
outName = `${base}.gif`;
|
||||
outBuf = buffer;
|
||||
} else {
|
||||
// Raster: über sharp als echtes Bild verifizieren, dann zu WebP.
|
||||
let meta;
|
||||
try { meta = await sharp(buffer).metadata(); } catch { meta = null; }
|
||||
if (!meta || !ALLOWED_RASTER.has(meta.format)) {
|
||||
return c.json({ error: 'Kein unterstütztes Bildformat' }, 400);
|
||||
}
|
||||
outName = `${base}.webp`;
|
||||
outBuf = await sharp(buffer)
|
||||
.rotate()
|
||||
|
||||
Reference in New Issue
Block a user