test: harden uploads and cover critical flows #15

Merged
Dada1981 merged 1 commit from codex/persistence-seo-hardening into main 2026-08-15 06:46:53 +00:00
6 changed files with 300 additions and 22 deletions
Showing only changes of commit 950846dd11 - Show all commits

View file

@ -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.

View file

@ -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",

115
server.ts
View file

@ -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<string, SessionRecord>();
const loginFailures = new Map<string, RateRecord>();
const geminiRequests = new Map<string, RateRecord>();
const uploadRequests = new Map<string, RateRecord>();
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<string, RateRecord>, 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<ProfileKey, string[]> = {
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");

View file

@ -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;
}
}

View file

@ -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
<input
ref={fileInputRef}
type="file"
accept="image/*"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
onChange={handleFileChange}
className="hidden"
/>
@ -298,7 +304,7 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
<Upload className="w-6 h-6" />
</div>
<p className="text-xs font-bold text-white">Bilddatei hierher ziehen oder anklicken</p>
<p className="text-[11px] text-slate-550 text-slate-500">Unterstützt JPG, PNG, GIF, WebP (max. 10MB)</p>
<p className="text-[11px] text-slate-550 text-slate-500">JPG, PNG, GIF, WebP oder AVIF (max. 8 MB)</p>
</div>
)}
</div>

170
tests/server.test.mjs Normal file
View file

@ -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, /<title>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 });
}
});
});