test: harden uploads and cover critical flows #15
6 changed files with 300 additions and 22 deletions
14
README.md
14
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.
|
- 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.
|
- `/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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@
|
||||||
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
|
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
|
||||||
"start": "node dist/server.cjs",
|
"start": "node dist/server.cjs",
|
||||||
"clean": "rm -rf dist",
|
"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": {
|
"dependencies": {
|
||||||
"@google/genai": "^2.4.0",
|
"@google/genai": "^2.4.0",
|
||||||
|
|
|
||||||
115
server.ts
115
server.ts
|
|
@ -10,7 +10,7 @@ import { AuthorData, AuthorProfile } from "./src/types.js";
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 3000;
|
const PORT = Number.parseInt(process.env.PORT || "3000", 10);
|
||||||
|
|
||||||
// Path to durable local database file
|
// Path to durable local database file
|
||||||
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data"));
|
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 LOGIN_MAX_FAILURES = 5;
|
||||||
const GEMINI_WINDOW_MS = 60 * 60 * 1000;
|
const GEMINI_WINDOW_MS = 60 * 60 * 1000;
|
||||||
const GEMINI_MAX_REQUESTS = 20;
|
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 {
|
interface SessionRecord {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
|
@ -53,12 +57,14 @@ interface RateRecord {
|
||||||
const sessions = new Map<string, SessionRecord>();
|
const sessions = new Map<string, SessionRecord>();
|
||||||
const loginFailures = new Map<string, RateRecord>();
|
const loginFailures = new Map<string, RateRecord>();
|
||||||
const geminiRequests = new Map<string, RateRecord>();
|
const geminiRequests = new Map<string, RateRecord>();
|
||||||
|
const uploadRequests = new Map<string, RateRecord>();
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [key, value] of sessions) if (value.expiresAt <= now) sessions.delete(key);
|
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 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 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();
|
}, 60 * 60 * 1000).unref();
|
||||||
|
|
||||||
const profileKeys = ["scifi", "erotica", "clara", "renee"] as const;
|
const profileKeys = ["scifi", "erotica", "clara", "renee"] as const;
|
||||||
|
|
@ -247,7 +253,7 @@ app.use((_req, res, next) => {
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
app.use(express.json({ limit: "10mb" }));
|
app.use(express.json({ limit: "12mb" }));
|
||||||
|
|
||||||
// Initialize Google GenAI if API key exists
|
// Initialize Google GenAI if API key exists
|
||||||
const getGeminiClient = () => {
|
const getGeminiClient = () => {
|
||||||
|
|
@ -366,12 +372,41 @@ function rateRecord(map: Map<string, RateRecord>, key: string, windowMs: number)
|
||||||
return existing;
|
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 {
|
function cleanDomain(value: string): string {
|
||||||
return value.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0];
|
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 {
|
function profileForRequest(hostname: string, pathname: string): ProfileKey {
|
||||||
const normalizedHost = cleanDomain(hostname);
|
|
||||||
const standardPaths: Record<ProfileKey, string[]> = {
|
const standardPaths: Record<ProfileKey, string[]> = {
|
||||||
erotica: ["/sensual-moments", "/annie-slone", "/marc-velvet"],
|
erotica: ["/sensual-moments", "/annie-slone", "/marc-velvet"],
|
||||||
clara: ["/clara-finch", "/clara"],
|
clara: ["/clara-finch", "/clara"],
|
||||||
|
|
@ -395,7 +430,7 @@ function profileForRequest(hostname: string, pathname: string): ProfileKey {
|
||||||
for (const key of profileKeys) {
|
for (const key of profileKeys) {
|
||||||
const configuredDomains = (dbCache[key].customDomain || "").split(",").map(cleanDomain).filter(Boolean);
|
const configuredDomains = (dbCache[key].customDomain || "").split(",").map(cleanDomain).filter(Boolean);
|
||||||
for (const domain of [...configuredDomains, ...standardDomains[key]]) {
|
for (const domain of [...configuredDomains, ...standardDomains[key]]) {
|
||||||
if (normalizedHost === domain || normalizedHost.endsWith(`.${domain}`)) return key;
|
if (matchesConfiguredDomain(hostname, domain)) return key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "scifi";
|
return "scifi";
|
||||||
|
|
@ -602,23 +637,56 @@ Die Synopsis soll neugierig machen, stark die visuelle Stimmung rüberbringen un
|
||||||
|
|
||||||
// 5. Upload a file via base64
|
// 5. Upload a file via base64
|
||||||
app.post("/api/admin/upload-file", verifySession, verifySameOrigin, async (req, res) => {
|
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;
|
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." });
|
res.status(400).json({ error: "Dateiname und Base64-Daten sind erforderlich." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Sanitize filename to prevent directory traversal
|
const match = base64Data.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([a-zA-Z0-9+/]+={0,2})$/);
|
||||||
const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9.\-_]/g, "_");
|
if (!match) {
|
||||||
const uploadPath = path.join(DATA_DIR, "uploads", safeName);
|
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 buffer = Buffer.from(encodedData, "base64");
|
||||||
const base64Clean = base64Data.replace(/^data:image\/\w+;base64,/, "");
|
if (buffer.length === 0 || buffer.length > MAX_UPLOAD_BYTES) {
|
||||||
const buffer = Buffer.from(base64Clean, "base64");
|
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);
|
const generatedName = `${Date.now().toString(36)}-${randomBytes(16).toString("hex")}.${detectedType.extension}`;
|
||||||
res.json({ success: true, url: `/uploads/${safeName}` });
|
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) {
|
} catch (err: any) {
|
||||||
console.error("File upload failed:", err);
|
console.error("File upload failed:", err);
|
||||||
res.status(500).json({ error: "Fehler beim Speichern der Datei auf dem Server." });
|
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 files = await fs.readdir(uploadsDir);
|
||||||
|
|
||||||
const fileList = files
|
const fileList = files
|
||||||
.filter(file => !file.startsWith(".")) // skip hidden files
|
.filter(file => SAFE_UPLOAD_NAME.test(file))
|
||||||
.map(file => ({
|
.map(file => ({
|
||||||
name: file,
|
name: file,
|
||||||
url: `/uploads/${file}`
|
url: `/uploads/${file}`
|
||||||
|
|
@ -649,7 +717,24 @@ app.get("/api/admin/list-uploads", verifySession, async (req, res) => {
|
||||||
// Configure Vite middleware or static serve
|
// Configure Vite middleware or static serve
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
// Serve the dynamic uploads directory statically
|
// 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") {
|
if (process.env.NODE_ENV !== "production") {
|
||||||
const { createServer: createViteServer } = await import("vite");
|
const { createServer: createViteServer } = await import("vite");
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ export default function App() {
|
||||||
for (const domain of customDomains) {
|
for (const domain of customDomains) {
|
||||||
// Entferne evtl. eingegebene Protokolle (http://, https://) oder führendes "www."
|
// Entferne evtl. eingegebene Protokolle (http://, https://) oder führendes "www."
|
||||||
const cleanDomain = domain.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();
|
const cleanDomain = domain.replace(/^https?:\/\//, "").replace(/^www\./, "").trim();
|
||||||
if (cleanDomain && (hostname === cleanDomain || hostname.includes(cleanDomain))) {
|
if (cleanDomain && (hostname === cleanDomain || hostname === `www.${cleanDomain}`)) {
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,13 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
||||||
|
|
||||||
const handleFileUpload = async (file: File) => {
|
const handleFileUpload = async (file: File) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!file.type.startsWith("image/")) {
|
const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
|
||||||
setError("Bitte wählen Sie nur Bilddateien (PNG, JPG, WebP, etc.) aus.");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,7 +90,8 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
const data = await response.json();
|
||||||
|
|
@ -282,7 +288,7 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
@ -298,7 +304,7 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
||||||
<Upload className="w-6 h-6" />
|
<Upload className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-bold text-white">Bilddatei hierher ziehen oder anklicken</p>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
170
tests/server.test.mjs
Normal file
170
tests/server.test.mjs
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue