211 lines
8.7 KiB
JavaScript
211 lines
8.7 KiB
JavaScript
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);
|
|
|
|
response = await fetch(`${baseUrl}/api/admin/author-data`);
|
|
assert.equal(response.status, 401);
|
|
|
|
response = await fetch(`${baseUrl}/api/admin/author-data`, { headers: { cookie } });
|
|
assert.equal(response.status, 200);
|
|
const adminData = await response.json();
|
|
adminData.erotica.customSectionLinks = [
|
|
{ label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" },
|
|
];
|
|
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
|
body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica }),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
|
|
response = await fetch(`${baseUrl}/api/admin/save-profile`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", origin: baseUrl, cookie },
|
|
body: JSON.stringify({
|
|
profileKey: "erotica",
|
|
profileData: { ...adminData.erotica, customSectionLinks: [{ label: "Unsicher", url: "javascript:alert(1)" }] },
|
|
}),
|
|
});
|
|
assert.equal(response.status, 400);
|
|
|
|
const publicResponse = await getWithHost(`${baseUrl}/api/author-data?path=/clara`, "annieslone.de");
|
|
assert.equal(publicResponse.status, 200);
|
|
const publicData = JSON.parse(publicResponse.body);
|
|
assert.deepEqual(Object.keys(publicData).sort(), ["legalDocuments", "profile", "theme"]);
|
|
assert.equal(publicData.profile.name, "Annie Slone");
|
|
assert.equal(publicData.theme, "velvet");
|
|
assert.equal(publicData.profile.customDomain, undefined);
|
|
assert.equal(publicData.profile.customPath, undefined);
|
|
assert.deepEqual(publicData.profile.customSectionLinks, [
|
|
{ label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" },
|
|
]);
|
|
assert.equal(publicResponse.body.includes("Clara Finch"), false);
|
|
assert.equal(publicResponse.body.includes("Renee Heart"), false);
|
|
assert.equal(publicResponse.body.includes("Daniel Hesse"), false);
|
|
|
|
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 });
|
|
}
|
|
});
|
|
});
|