From 950846dd11a3024f7a1e6518fa6aa1f73ba56be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20He=C3=9Fe?= Date: Sat, 15 Aug 2026 08:45:07 +0200 Subject: [PATCH] test: harden uploads and cover critical flows --- README.md | 14 +++ package.json | 5 +- server.ts | 115 +++++++++++++++++++--- src/App.tsx | 2 +- src/components/ImagePicker.tsx | 16 +++- tests/server.test.mjs | 170 +++++++++++++++++++++++++++++++++ 6 files changed, 300 insertions(+), 22 deletions(-) create mode 100644 tests/server.test.mjs diff --git a/README.md b/README.md index b7636d4..eb7e060 100644 --- a/README.md +++ b/README.md @@ -104,3 +104,17 @@ Beim Ergänzen neuer Datenfelder legt die Anwendung vor der Migration automatisc - Seitentitel, Beschreibung, Canonical URL, Open-Graph-Daten und strukturierte Personendaten werden passend zur aufgerufenen Autorendomain serverseitig ausgegeben. - `/robots.txt` und `/sitemap.xml` werden ebenfalls profilabhängig erzeugt; der Adminbereich ist mit `noindex` gekennzeichnet. - Optionale SEO-Felder können später je Profil gepflegt werden. Ohne sie werden die Angaben rückwärtskompatibel aus den vorhandenen Profildaten abgeleitet. + +### Bild-Uploads + +Der Adminbereich akzeptiert JPEG, PNG, WebP, GIF und AVIF bis maximal 8 MB. Der Server prüft den tatsächlichen Dateikopf unabhängig von Dateiname und Browserangabe, erzeugt einen zufälligen nicht überschreibbaren Namen und begrenzt Uploads auf 30 Versuche pro Sitzung und Stunde. SVG, HTML und andere aktive Dateiformate werden weder angenommen noch aus dem Uploadverzeichnis ausgeliefert. + +### Qualitätschecks + +```bash +npm run typecheck # TypeScript ohne Ausgabe prüfen +npm test # Produktionsbuild und Integrationstests +npm run check # vollständiger Check aus TypeScript, Build und Tests +``` + +Die Integrationstests verwenden ausschließlich temporäre Datenverzeichnisse und einen kurzlebigen lokalen Server. Sie prüfen Produktions-Secrets, Admin-Sitzung und Logout, Origin-Schutz, Login-Limit, exaktes Domain-Routing sowie gültige und manipulierte Bild-Uploads. diff --git a/package.json b/package.json index adead72..451c04b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs", "start": "node dist/server.cjs", "clean": "rm -rf dist", - "lint": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "lint": "npm run typecheck", + "test": "npm run build && node --test tests/*.test.mjs", + "check": "npm run typecheck && npm test" }, "dependencies": { "@google/genai": "^2.4.0", diff --git a/server.ts b/server.ts index 07ac76e..6684965 100644 --- a/server.ts +++ b/server.ts @@ -10,7 +10,7 @@ import { AuthorData, AuthorProfile } from "./src/types.js"; dotenv.config(); const app = express(); -const PORT = 3000; +const PORT = Number.parseInt(process.env.PORT || "3000", 10); // Path to durable local database file const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data")); @@ -40,6 +40,10 @@ const LOGIN_WINDOW_MS = 15 * 60 * 1000; const LOGIN_MAX_FAILURES = 5; const GEMINI_WINDOW_MS = 60 * 60 * 1000; const GEMINI_MAX_REQUESTS = 20; +const UPLOAD_WINDOW_MS = 60 * 60 * 1000; +const UPLOAD_MAX_REQUESTS = 30; +const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; +const SAFE_UPLOAD_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.(?:jpe?g|png|webp|gif|avif)$/i; interface SessionRecord { expiresAt: number; @@ -53,12 +57,14 @@ interface RateRecord { const sessions = new Map(); const loginFailures = new Map(); const geminiRequests = new Map(); +const uploadRequests = new Map(); setInterval(() => { const now = Date.now(); for (const [key, value] of sessions) if (value.expiresAt <= now) sessions.delete(key); for (const [key, value] of loginFailures) if (value.resetAt <= now) loginFailures.delete(key); for (const [key, value] of geminiRequests) if (value.resetAt <= now) geminiRequests.delete(key); + for (const [key, value] of uploadRequests) if (value.resetAt <= now) uploadRequests.delete(key); }, 60 * 60 * 1000).unref(); const profileKeys = ["scifi", "erotica", "clara", "renee"] as const; @@ -247,7 +253,7 @@ app.use((_req, res, next) => { } next(); }); -app.use(express.json({ limit: "10mb" })); +app.use(express.json({ limit: "12mb" })); // Initialize Google GenAI if API key exists const getGeminiClient = () => { @@ -366,12 +372,41 @@ function rateRecord(map: Map, key: string, windowMs: number) return existing; } +interface DetectedImageType { + extension: "jpg" | "png" | "webp" | "gif" | "avif"; + mimeType: string; +} + +function detectImageType(buffer: Buffer): DetectedImageType | null { + if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return { extension: "jpg", mimeType: "image/jpeg" }; + } + if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + return { extension: "png", mimeType: "image/png" }; + } + if (buffer.length >= 12 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") { + return { extension: "webp", mimeType: "image/webp" }; + } + if (buffer.length >= 6 && ["GIF87a", "GIF89a"].includes(buffer.toString("ascii", 0, 6))) { + return { extension: "gif", mimeType: "image/gif" }; + } + if (buffer.length >= 12 && buffer.toString("ascii", 4, 8) === "ftyp" && ["avif", "avis"].includes(buffer.toString("ascii", 8, 12))) { + return { extension: "avif", mimeType: "image/avif" }; + } + return null; +} + function cleanDomain(value: string): string { return value.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0]; } +function matchesConfiguredDomain(hostname: string, configuredDomain: string): boolean { + const domain = cleanDomain(configuredDomain); + const host = cleanDomain(hostname); + return !!domain && (host === domain || hostname.toLowerCase() === `www.${domain}`); +} + function profileForRequest(hostname: string, pathname: string): ProfileKey { - const normalizedHost = cleanDomain(hostname); const standardPaths: Record = { erotica: ["/sensual-moments", "/annie-slone", "/marc-velvet"], clara: ["/clara-finch", "/clara"], @@ -395,7 +430,7 @@ function profileForRequest(hostname: string, pathname: string): ProfileKey { for (const key of profileKeys) { const configuredDomains = (dbCache[key].customDomain || "").split(",").map(cleanDomain).filter(Boolean); for (const domain of [...configuredDomains, ...standardDomains[key]]) { - if (normalizedHost === domain || normalizedHost.endsWith(`.${domain}`)) return key; + if (matchesConfiguredDomain(hostname, domain)) return key; } } return "scifi"; @@ -602,23 +637,56 @@ Die Synopsis soll neugierig machen, stark die visuelle Stimmung rüberbringen un // 5. Upload a file via base64 app.post("/api/admin/upload-file", verifySession, verifySameOrigin, async (req, res) => { + const sessionId = res.locals.sessionId as string; + const rate = rateRecord(uploadRequests, sessionId, UPLOAD_WINDOW_MS); + if (rate.count >= UPLOAD_MAX_REQUESTS) { + res.setHeader("Retry-After", Math.ceil((rate.resetAt - Date.now()) / 1000)); + res.status(429).json({ error: "Das stündliche Upload-Limit ist erreicht." }); + return; + } + rate.count += 1; + const { fileName, base64Data } = req.body; - if (!fileName || !base64Data) { + if (typeof fileName !== "string" || typeof base64Data !== "string") { res.status(400).json({ error: "Dateiname und Base64-Daten sind erforderlich." }); return; } try { - // Sanitize filename to prevent directory traversal - const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9.\-_]/g, "_"); - const uploadPath = path.join(DATA_DIR, "uploads", safeName); + const match = base64Data.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([a-zA-Z0-9+/]+={0,2})$/); + if (!match) { + res.status(400).json({ error: "Das Uploadformat ist ungültig." }); + return; + } + const [, declaredMimeType, encodedData] = match; + const estimatedBytes = Math.floor(encodedData.length * 3 / 4); + if (estimatedBytes > MAX_UPLOAD_BYTES) { + res.status(413).json({ error: "Das Bild darf maximal 8 MB groß sein." }); + return; + } - // Strip base64 metadata prefix if present (e.g., "data:image/jpeg;base64,") - const base64Clean = base64Data.replace(/^data:image\/\w+;base64,/, ""); - const buffer = Buffer.from(base64Clean, "base64"); + const buffer = Buffer.from(encodedData, "base64"); + if (buffer.length === 0 || buffer.length > MAX_UPLOAD_BYTES) { + res.status(413).json({ error: "Das Bild ist leer oder überschreitet 8 MB." }); + return; + } + const detectedType = detectImageType(buffer); + if (!detectedType) { + res.status(415).json({ error: "Erlaubt sind ausschließlich echte JPEG-, PNG-, WebP-, GIF- oder AVIF-Bilder." }); + return; + } + const normalizedDeclaredType = declaredMimeType === "image/jpg" ? "image/jpeg" : declaredMimeType; + const suppliedExtension = path.extname(path.basename(fileName)).slice(1).toLowerCase(); + const normalizedExtension = ["jpeg", "jfif"].includes(suppliedExtension) ? "jpg" : suppliedExtension; + if (normalizedDeclaredType !== detectedType.mimeType || normalizedExtension !== detectedType.extension) { + res.status(415).json({ error: "Dateiendung, MIME-Typ und tatsächlicher Bildinhalt stimmen nicht überein." }); + return; + } - await fs.writeFile(uploadPath, buffer); - res.json({ success: true, url: `/uploads/${safeName}` }); + const generatedName = `${Date.now().toString(36)}-${randomBytes(16).toString("hex")}.${detectedType.extension}`; + const uploadPath = path.join(DATA_DIR, "uploads", generatedName); + await fs.writeFile(uploadPath, buffer, { flag: "wx" }); + res.status(201).json({ success: true, url: `/uploads/${generatedName}`, mimeType: detectedType.mimeType, size: buffer.length }); } catch (err: any) { console.error("File upload failed:", err); res.status(500).json({ error: "Fehler beim Speichern der Datei auf dem Server." }); @@ -633,7 +701,7 @@ app.get("/api/admin/list-uploads", verifySession, async (req, res) => { const files = await fs.readdir(uploadsDir); const fileList = files - .filter(file => !file.startsWith(".")) // skip hidden files + .filter(file => SAFE_UPLOAD_NAME.test(file)) .map(file => ({ name: file, url: `/uploads/${file}` @@ -649,7 +717,24 @@ app.get("/api/admin/list-uploads", verifySession, async (req, res) => { // Configure Vite middleware or static serve async function startServer() { // Serve the dynamic uploads directory statically - app.use("/uploads", express.static(path.join(DATA_DIR, "uploads"))); + app.use("/uploads", (req, res, next) => { + const requestedName = path.basename(req.path); + if (!SAFE_UPLOAD_NAME.test(requestedName)) { + res.status(404).end(); + return; + } + next(); + }); + app.use("/uploads", express.static(path.join(DATA_DIR, "uploads"), { + dotfiles: "deny", + fallthrough: false, + immutable: true, + maxAge: "1y", + setHeaders: (res) => { + res.setHeader("Content-Disposition", "inline"); + res.setHeader("X-Content-Type-Options", "nosniff"); + }, + })); if (process.env.NODE_ENV !== "production") { const { createServer: createViteServer } = await import("vite"); diff --git a/src/App.tsx b/src/App.tsx index b5e292c..64357b6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -93,7 +93,7 @@ export default function App() { for (const domain of customDomains) { // Entferne evtl. eingegebene Protokolle (http://, https://) oder führendes "www." const cleanDomain = domain.replace(/^https?:\/\//, "").replace(/^www\./, "").trim(); - if (cleanDomain && (hostname === cleanDomain || hostname.includes(cleanDomain))) { + if (cleanDomain && (hostname === cleanDomain || hostname === `www.${cleanDomain}`)) { return key; } } diff --git a/src/components/ImagePicker.tsx b/src/components/ImagePicker.tsx index b53268a..0f1e6cf 100644 --- a/src/components/ImagePicker.tsx +++ b/src/components/ImagePicker.tsx @@ -59,8 +59,13 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps const handleFileUpload = async (file: File) => { if (!file) return; - if (!file.type.startsWith("image/")) { - setError("Bitte wählen Sie nur Bilddateien (PNG, JPG, WebP, etc.) aus."); + const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]); + if (!allowedTypes.has(file.type)) { + setError("Erlaubt sind ausschließlich JPEG-, PNG-, WebP-, GIF- oder AVIF-Bilder."); + return; + } + if (file.size > 8 * 1024 * 1024) { + setError("Das Bild darf maximal 8 MB groß sein."); return; } @@ -85,7 +90,8 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps }); if (!response.ok) { - throw new Error("Serverfehler beim Upload."); + const responseData = await response.json().catch(() => null); + throw new Error(responseData?.error || "Serverfehler beim Upload."); } const data = await response.json(); @@ -282,7 +288,7 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps @@ -298,7 +304,7 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps

Bilddatei hierher ziehen oder anklicken

-

Unterstützt JPG, PNG, GIF, WebP (max. 10MB)

+

JPG, PNG, GIF, WebP oder AVIF (max. 8 MB)

)} diff --git a/tests/server.test.mjs b/tests/server.test.mjs new file mode 100644 index 0000000..f27dab6 --- /dev/null +++ b/tests/server.test.mjs @@ -0,0 +1,170 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer } from "node:net"; +import { get as httpGet } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const serverEntry = path.join(projectDir, "dist", "server.cjs"); + +async function freePort() { + const server = createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close(); + await once(server, "close"); + return port; +} + +function waitForServer(child) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Testserver startete nicht rechtzeitig.")), 10_000); + const onData = (chunk) => { + if (chunk.toString().includes("Server running")) { + clearTimeout(timeout); + child.stdout.off("data", onData); + resolve(); + } + }; + child.stdout.on("data", onData); + child.once("exit", (code) => { + clearTimeout(timeout); + reject(new Error(`Testserver endete vorzeitig mit Code ${code}.`)); + }); + }); +} + +async function stopServer(child) { + if (child.exitCode !== null) return; + child.kill("SIGTERM"); + await once(child, "close"); +} + +function getWithHost(baseUrl, host) { + return new Promise((resolve, reject) => { + const request = httpGet(baseUrl, { headers: { host } }, (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { body += chunk; }); + response.on("end", () => resolve({ status: response.statusCode, body })); + }); + request.on("error", reject); + }); +} + +test("production hardening and public routing", async (t) => { + await t.test("production refuses to start without secrets", async () => { + const dataDir = await mkdtemp(path.join(tmpdir(), "author-missing-secrets-")); + const env = { ...process.env, NODE_ENV: "production", DATA_DIR: dataDir }; + delete env.ADMIN_PASSWORD; + delete env.SESSION_SECRET; + const child = spawn(process.execPath, [serverEntry], { cwd: projectDir, env, stdio: ["ignore", "pipe", "pipe"] }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; }); + const [code] = await once(child, "close"); + assert.equal(code, 1); + assert.match(stderr, /ADMIN_PASSWORD, SESSION_SECRET/); + await rm(dataDir, { recursive: true, force: true }); + }); + + await t.test("sessions, rate limits, domains and uploads work securely", async () => { + const dataDir = await mkdtemp(path.join(tmpdir(), "author-server-test-")); + const port = await freePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const child = spawn(process.execPath, [serverEntry], { + cwd: projectDir, + env: { + ...process.env, + NODE_ENV: "production", + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_PASSWORD: "test-password-12345", + SESSION_SECRET: "test-session-secret-12345678901234567890", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + try { + await waitForServer(child); + + let response = await fetch(`${baseUrl}/api/admin/session`); + assert.equal(response.status, 401); + + response = await fetch(`${baseUrl}/api/admin/login`, { + method: "POST", + headers: { "content-type": "application/json", origin: baseUrl }, + body: JSON.stringify({ password: "test-password-12345" }), + }); + assert.equal(response.status, 200); + const setCookie = response.headers.get("set-cookie") || ""; + assert.match(setCookie, /HttpOnly/i); + assert.match(setCookie, /Secure/i); + assert.match(setCookie, /SameSite=Strict/i); + assert.doesNotMatch(setCookie, /test-password/); + const cookie = setCookie.split(";")[0]; + + response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } }); + assert.equal(response.status, 200); + + let routedPage = await getWithHost(`${baseUrl}/`, "annieslone.de"); + assert.match(routedPage.body, /Annie Slone/); + routedPage = await getWithHost(`${baseUrl}/`, "www.annieslone.de"); + assert.match(routedPage.body, /<title>Annie Slone/); + routedPage = await getWithHost(`${baseUrl}/`, "blog.annieslone.de"); + assert.match(routedPage.body, /<title>Daniel Hesse/); + + const validPng = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + const upload = (fileName, base64Data) => fetch(`${baseUrl}/api/admin/upload-file`, { + method: "POST", + headers: { "content-type": "application/json", origin: baseUrl, cookie }, + body: JSON.stringify({ fileName, base64Data }), + }); + + response = await upload("cover.png", validPng); + assert.equal(response.status, 201); + const uploaded = await response.json(); + assert.match(uploaded.url, /^\/uploads\/[a-z0-9-]+\.png$/); + assert.equal(uploaded.mimeType, "image/png"); + + response = await upload("fake.png", "data:image/png;base64,SGVsbG8="); + assert.equal(response.status, 415); + response = await upload("wrong.jpg", validPng); + assert.equal(response.status, 415); + + response = await fetch(`${baseUrl}${uploaded.url}`); + assert.equal(response.status, 200); + assert.equal(response.headers.get("x-content-type-options"), "nosniff"); + assert.match(response.headers.get("cache-control") || "", /immutable/); + + response = await fetch(`${baseUrl}/api/admin/logout`, { method: "POST", headers: { origin: baseUrl, cookie } }); + assert.equal(response.status, 200); + response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } }); + assert.equal(response.status, 401); + + const attempts = []; + for (let index = 0; index < 6; index += 1) { + const attempt = await fetch(`${baseUrl}/api/admin/login`, { + method: "POST", + headers: { "content-type": "application/json", origin: baseUrl }, + body: JSON.stringify({ password: "wrong-password" }), + }); + attempts.push(attempt.status); + } + assert.deepEqual(attempts, [401, 401, 401, 401, 401, 429]); + + response = await fetch(`${baseUrl}/`); + assert.equal(response.headers.get("x-frame-options"), "DENY"); + assert.ok(response.headers.get("content-security-policy")); + } finally { + await stopServer(child); + await rm(dataDir, { recursive: true, force: true }); + } + }); +}); -- 2.45.2