Harden deployment security

This commit is contained in:
Codex
2026-07-21 12:28:06 -05:00
parent 5c2ac5f7e1
commit 63c3a1b0f8
15 changed files with 286 additions and 45 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.env
.git
.gitignore
node_modules
storage
public/uploads/*
!public/uploads/.gitkeep
!public/uploads/sample-hero.png
npm-debug.log*
server.*.log
*.pid

View File

@@ -7,6 +7,13 @@ SECONDARY_DOMAIN=debbiewindler.com
# Generate a long random value before production.
SESSION_SECRET=replace-with-a-long-random-secret
# Required before the first owner account can be created.
# Open /admin/setup?token=the-value-here during first setup.
SETUP_TOKEN=replace-with-a-long-random-one-time-setup-token
# Use "loopback" when a local reverse proxy such as Caddy/Nginx forwards to Node.
TRUST_PROXY=loopback
# SQLite and local file storage.
DATABASE_PATH=./storage/site.sqlite
UPLOAD_DIR=./public/uploads

2
.gitignore vendored
View File

@@ -3,6 +3,8 @@ node_modules/
storage/*.sqlite
storage/*.sqlite-shm
storage/*.sqlite-wal
storage/*.pid
storage/*.log
storage/backups/*.zip
public/uploads/*
!public/uploads/.gitkeep

View File

@@ -46,8 +46,8 @@ If `pnpm` is not installed globally, `npm install` and `npm run dev` also work.
## First Owner Setup
1. Start the site.
2. Open `http://localhost:3000/admin`.
3. The app will redirect to `/admin/setup` until the first owner account is created.
2. Set a private `SETUP_TOKEN` in `.env`.
3. Open `http://localhost:3000/admin/setup?token=YOUR_SETUP_TOKEN`.
4. Enter owner name, email, and a password with at least 10 characters.
5. After setup, future visits to `/admin` use the owner login screen.
@@ -92,6 +92,8 @@ Important values:
- `PORT`: app port, default `3000`.
- `APP_BASE_URL`: final public URL, for example `https://debbiewindlerseamstress.com`.
- `SESSION_SECRET`: long random secret for sessions.
- `SETUP_TOKEN`: private one-time setup token required before the first owner account can be created.
- `TRUST_PROXY`: use `loopback` for a local reverse proxy; avoid broad proxy trust unless the proxy is configured to strip client-supplied forwarding headers.
- `DATABASE_PATH`: SQLite file path.
- `UPLOAD_DIR`: uploaded image folder.
- `BACKUP_DIR`: backup output folder.
@@ -123,7 +125,8 @@ Recommended production shape:
2. Put a reverse proxy such as Caddy, Nginx, or IIS ARR in front of it.
3. Configure TLS for `debbiewindlerseamstress.com`.
4. Later, redirect `debbiewindler.com` to `debbiewindlerseamstress.com`.
5. Set `NODE_ENV=production`, `APP_BASE_URL`, `SESSION_SECRET`, SMTP values, and owner email in `.env`.
5. Set `NODE_ENV=production`, `APP_BASE_URL`, `SESSION_SECRET`, `SETUP_TOKEN`, `TRUST_PROXY`, SMTP values, and owner email in `.env`.
6. Create the first owner account using `/admin/setup?token=YOUR_SETUP_TOKEN`.
This project does not make DNS, router, firewall, reverse proxy, or live production changes.
@@ -185,6 +188,7 @@ Manual checklist before going live:
## Security Checklist
- Set a long `SESSION_SECRET`.
- Set a private `SETUP_TOKEN` before first boot in production.
- Use HTTPS in production.
- Keep `.env` private.
- Use a strong owner password.

View File

@@ -18,8 +18,9 @@
"bcryptjs": "^2.4.3",
"express": "^4.19.2",
"express-session": "^1.18.1",
"helmet": "^8.3.0",
"multer": "^2.2.0",
"nodemailer": "^6.9.14",
"nodemailer": "^9.0.3",
"sanitize-html": "^2.13.0",
"sharp": "^0.33.5",
"slugify": "^1.6.6"

19
pnpm-lock.yaml generated
View File

@@ -20,12 +20,15 @@ importers:
express-session:
specifier: ^1.18.1
version: 1.19.0
helmet:
specifier: ^8.3.0
version: 8.3.0
multer:
specifier: ^2.2.0
version: 2.2.0
nodemailer:
specifier: ^6.9.14
version: 6.10.1
specifier: ^9.0.3
version: 9.0.3
sanitize-html:
specifier: ^2.13.0
version: 2.17.6
@@ -572,6 +575,10 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
helmet@8.3.0:
resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==}
engines: {node: '>=18.0.0'}
htmlparser2@12.0.0:
resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==}
engines: {node: '>=20.19.0'}
@@ -695,8 +702,8 @@ packages:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
nodemailer@6.10.1:
resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==}
nodemailer@9.0.3:
resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==}
engines: {node: '>=6.0.0'}
normalize-path@3.0.0:
@@ -1487,6 +1494,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
helmet@8.3.0: {}
htmlparser2@12.0.0:
dependencies:
domelementtype: 3.0.0
@@ -1585,7 +1594,7 @@ snapshots:
negotiator@0.6.3: {}
nodemailer@6.10.1: {}
nodemailer@9.0.3: {}
normalize-path@3.0.0: {}

View File

@@ -12,14 +12,30 @@ function fromRoot(value, fallback) {
return path.isAbsolute(selected) ? selected : path.join(root, selected);
}
const env = process.env.NODE_ENV || "development";
const production = env === "production";
const sessionSecret = process.env.SESSION_SECRET || "";
const setupToken = process.env.SETUP_TOKEN || "";
if (production) {
if (!sessionSecret || sessionSecret === "development-only-change-me" || sessionSecret.length < 32) {
throw new Error("Refusing to start in production without a strong SESSION_SECRET of at least 32 characters.");
}
if (!setupToken || setupToken.length < 24) {
throw new Error("Refusing to start in production without a SETUP_TOKEN of at least 24 characters.");
}
}
module.exports = {
root,
env: process.env.NODE_ENV || "development",
env,
port: Number(process.env.PORT || 3000),
baseUrl: process.env.APP_BASE_URL || "http://localhost:3000",
primaryDomain: process.env.PRIMARY_DOMAIN || "debbiewindlerseamstress.com",
secondaryDomain: process.env.SECONDARY_DOMAIN || "debbiewindler.com",
sessionSecret: process.env.SESSION_SECRET || "development-only-change-me",
sessionSecret: sessionSecret || "development-only-change-me",
setupToken,
trustProxy: process.env.TRUST_PROXY || "loopback",
databasePath: fromRoot(process.env.DATABASE_PATH, "./storage/site.sqlite"),
uploadDir: fromRoot(process.env.UPLOAD_DIR, "./public/uploads"),
backupDir: fromRoot(process.env.BACKUP_DIR, "./storage/backups"),
@@ -37,5 +53,5 @@ module.exports = {
siteKey: process.env.CAPTCHA_SITE_KEY || "",
secretKey: process.env.CAPTCHA_SECRET_KEY || ""
},
production: process.env.NODE_ENV === "production"
production
};

View File

@@ -327,8 +327,8 @@ function seed() {
"theme.radius": "8",
"theme.spacing": "comfortable",
"theme.width": "1120",
"theme.heading_font": "Georgia, 'Times New Roman', serif",
"theme.body_font": "Arial, Helvetica, sans-serif",
"theme.heading_font": "serif",
"theme.body_font": "sans",
"theme.footer_style": "light",
"home.hero.title": "Debbie Windler Seamstress",
"home.hero.subtitle": "Alterations, custom sewing, embroidery, and handmade pieces, with sample wording ready for Debbie to replace.",

View File

@@ -1,4 +1,12 @@
const { escapeHtml, cleanRichText, bytes } = require("./utils");
const {
escapeHtml,
cleanRichText,
bytes,
safeCssColor,
safeCssNumber,
safeCssKeyword,
safeCssUrl
} = require("./utils");
function setting(settings, key, fallback = "") {
return settings[key] ?? fallback;
@@ -130,20 +138,30 @@ function authLayout(title, body) {
function themeVars(settings) {
const keys = {
"--bg": ["theme.bg", "#fffaf5"],
"--bg-alt": ["theme.bg_alt", "#f4e8df"],
"--text": ["theme.text", "#342b28"],
"--heading": ["theme.heading", "#2d2522"],
"--link": ["theme.link", "#7d3f45"],
"--button": ["theme.button", "#7d3f45"],
"--button-text": ["theme.button_text", "#ffffff"],
"--accent": ["theme.accent", "#7a8f73"],
"--radius": ["theme.radius", "8"],
"--site-width": ["theme.width", "1120"]
"--bg": ["theme.bg", "#fffaf5", "color"],
"--bg-alt": ["theme.bg_alt", "#f4e8df", "color"],
"--text": ["theme.text", "#342b28", "color"],
"--heading": ["theme.heading", "#2d2522", "color"],
"--link": ["theme.link", "#7d3f45", "color"],
"--button": ["theme.button", "#7d3f45", "color"],
"--button-text": ["theme.button_text", "#ffffff", "color"],
"--accent": ["theme.accent", "#7a8f73", "color"],
"--radius": ["theme.radius", "8", "number"],
"--site-width": ["theme.width", "1120", "width"]
};
const css = Object.entries(keys).map(([name, [key, fallback]]) => `${name}:${escapeHtml(setting(settings, key, fallback))}${name === "--radius" || name === "--site-width" ? "px" : ""};`);
css.push(`--heading-font:${escapeHtml(setting(settings, "theme.heading_font", "Georgia, 'Times New Roman', serif"))};`);
css.push(`--body-font:${escapeHtml(setting(settings, "theme.body_font", "Arial, Helvetica, sans-serif"))};`);
const css = Object.entries(keys).map(([name, [key, fallback, kind]]) => {
if (kind === "color") return `${name}:${safeCssColor(setting(settings, key, fallback), fallback)};`;
if (kind === "number") return `${name}:${safeCssNumber(setting(settings, key, fallback), Number(fallback), 0, 32)}px;`;
return `${name}:${safeCssNumber(setting(settings, key, fallback), Number(fallback), 760, 1600)}px;`;
});
const fonts = {
serif: "Georgia, 'Times New Roman', serif",
sans: "Arial, Helvetica, sans-serif",
verdana: "Verdana, Geneva, sans-serif",
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
};
css.push(`--heading-font:${fonts[safeCssKeyword(setting(settings, "theme.heading_font", "serif"), Object.keys(fonts), "serif")]};`);
css.push(`--body-font:${fonts[safeCssKeyword(setting(settings, "theme.body_font", "sans"), Object.keys(fonts), "sans")]};`);
return css.join("");
}
@@ -234,8 +252,8 @@ function contactForm({ csrfToken, item = "", service = "", success = false, sett
function sectionHtml(section) {
if (!section.is_published) return "";
const style = [
section.background_color ? `background-color:${escapeHtml(section.background_color)}` : "",
section.background_url ? `background-image:url('${escapeHtml(section.background_url)}')` : ""
section.background_color ? `background-color:${safeCssColor(section.background_color, "transparent")}` : "",
safeCssUrl(section.background_url) ? `background-image:url('${escapeHtml(safeCssUrl(section.background_url))}')` : ""
].filter(Boolean).join(";");
const image = section.image_url ? `<img src="${escapeHtml(section.image_url)}" alt="${escapeHtml(section.image_alt || section.title || "")}" loading="lazy">` : "";
const content = `<div class="section-copy">

View File

@@ -3,6 +3,7 @@ const path = require("path");
const crypto = require("crypto");
const express = require("express");
const session = require("express-session");
const helmet = require("helmet");
const bcrypt = require("bcryptjs");
const multer = require("multer");
const sharp = require("sharp");
@@ -27,7 +28,11 @@ const {
bytes,
checkbox,
listFromText,
safeJson
safeJson,
safeCssColor,
safeCssNumber,
safeCssKeyword,
safeCssUrl
} = require("./utils");
const {
publicLayout,
@@ -44,7 +49,32 @@ const {
} = require("./render");
const app = express();
app.set("trust proxy", 1);
app.set("trust proxy", config.trustProxy);
app.disable("x-powered-by");
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: config.production ? [] : null
}
},
crossOriginOpenerPolicy: { policy: "same-origin" },
frameguard: { action: "sameorigin" },
hsts: config.production ? { maxAge: 31536000, includeSubDomains: true } : false,
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
noSniff: true
}));
app.use((req, res, next) => {
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()");
next();
});
class SQLiteSessionStore extends session.Store {
get(sid, cb) {
@@ -99,12 +129,24 @@ app.use((req, res, next) => {
res.locals.csrfToken = req.session.csrfToken;
next();
});
app.use((req, res, next) => {
function csrfCheck(req, res, next) {
if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {
const submitted = req.body?._csrf || req.headers["x-csrf-token"];
if (!submitted || submitted !== req.session.csrfToken) return res.status(403).send("Security check failed. Please go back and try again.");
}
next();
}
function multipartCsrfAfterParser(req) {
if (req.method !== "POST") return false;
const type = String(req.headers["content-type"] || "").toLowerCase();
if (!type.startsWith("multipart/form-data")) return false;
return /^\/admin\/(items\/(new|\d+\/edit)|services\/(new|\d+\/edit)|media)$/.test(req.path);
}
app.use((req, res, next) => {
if (multipartCsrfAfterParser(req)) return next();
csrfCheck(req, res, next);
});
app.use("/css", express.static(path.join(config.root, "public/css"), { maxAge: "7d" }));
app.use("/js", express.static(path.join(config.root, "public/js"), { maxAge: "7d" }));
@@ -135,6 +177,16 @@ function requireAdmin(req, res, next) {
next();
}
function setupAuthorized(req) {
const submitted = req.body?.setup_token || req.query?.token || req.headers["x-setup-token"];
return Boolean(config.setupToken && submitted && submitted === config.setupToken);
}
function requireSetupToken(req, res, next) {
if (setupAuthorized(req)) return next();
return res.status(403).send(authLayout("Owner Setup Locked", "<p>Owner setup requires the private setup link or setup token from the server administrator.</p>"));
}
function navLinks() {
return db.prepare("SELECT * FROM navigation WHERE is_published = 1 ORDER BY display_order, id").all();
}
@@ -295,9 +347,15 @@ app.get("/", (req, res) => {
const featured = publicItems("AND (i.featured = 1 OR i.newly_added = 1 OR i.on_sale = 1)").slice(0, 6);
const home = pageBySlug("home");
const sections = pageSections(home.id);
const overlay = setting(settings, "home.hero.overlay", "light");
const overlay = safeCssKeyword(setting(settings, "home.hero.overlay", "light"), ["light", "dark", "none"], "light");
const heroImage = safeCssUrl(setting(settings, "home.hero.image", ""));
const heroPosition = safeCssKeyword(setting(settings, "home.hero.position", "center"), ["center", "top", "bottom", "left", "right"], "center");
const heroSize = safeCssKeyword(setting(settings, "home.hero.size", "cover"), ["cover", "contain", "auto"], "cover");
const heroOpacity = safeCssNumber(setting(settings, "home.hero.overlay_opacity", "0.28"), 0.28, 0, 0.9);
const heroTextColor = safeCssColor(setting(settings, "home.hero.text_color", "#2d2522"), "#2d2522");
const highlightColor = safeCssColor(setting(settings, "home.highlight.background_color", "#f4e8df"), "#f4e8df");
const body = `
<section class="hero ${overlay}" style="background-image:url('${escapeHtml(setting(settings, "home.hero.image", ""))}');background-position:${escapeHtml(setting(settings, "home.hero.position", "center"))};background-size:${escapeHtml(setting(settings, "home.hero.size", "cover"))};--overlay-opacity:${escapeHtml(setting(settings, "home.hero.overlay_opacity", "0.28"))};color:${escapeHtml(setting(settings, "home.hero.text_color", "#2d2522"))}">
<section class="hero ${overlay}" style="${heroImage ? `background-image:url('${escapeHtml(heroImage)}');` : ""}background-position:${heroPosition};background-size:${heroSize};--overlay-opacity:${heroOpacity};color:${heroTextColor}">
<div class="hero-inner">
<p class="eyebrow">${escapeHtml(setting(settings, "business.tagline", ""))}</p>
<h1>${escapeHtml(setting(settings, "home.hero.title", "Debbie Windler Seamstress"))}</h1>
@@ -309,7 +367,7 @@ app.get("/", (req, res) => {
</div>
</div>
</section>
<section class="showcase" style="background:${escapeHtml(setting(settings, "home.highlight.background_color", "#f4e8df"))}">
<section class="showcase" style="background:${highlightColor}">
<div class="section-heading">
<p class="eyebrow">Showcase</p>
<h2>${escapeHtml(setting(settings, "home.highlight.title", "Featured and Newly Added"))}</h2>
@@ -484,9 +542,11 @@ app.get("/admin", (req, res) => {
app.get("/admin/setup", (req, res) => {
if (adminCount()) return res.redirect("/admin/login");
if (!setupAuthorized(req)) return requireSetupToken(req, res, () => {});
res.send(authLayout("Create Owner Account", `<p>Create the first owner login. No password is stored as plain text.</p>
<form method="post">
<input type="hidden" name="_csrf" value="${escapeHtml(res.locals.csrfToken)}">
<input type="hidden" name="setup_token" value="${escapeHtml(req.query.token || "")}">
<label>Name <input required name="name" maxlength="120"></label>
<label>Email <input required type="email" name="email" maxlength="180"></label>
<label>Password <input required type="password" name="password" minlength="10"></label>
@@ -494,7 +554,7 @@ app.get("/admin/setup", (req, res) => {
</form>`));
});
app.post("/admin/setup", async (req, res) => {
app.post("/admin/setup", requireSetupToken, async (req, res) => {
if (adminCount()) return res.redirect("/admin/login");
const password = String(req.body.password || "");
if (password.length < 10) return res.status(400).send("Please use a password with at least 10 characters.");
@@ -732,13 +792,13 @@ app.get("/admin/items/:id/edit", requireAdmin, (req, res) => {
renderAdmin(req, res, `Edit ${item.name}`, "items", itemEditor(item, res.locals.csrfToken));
});
app.post("/admin/items/new", requireAdmin, upload.array("images", 8), async (req, res, next) => {
app.post("/admin/items/new", requireAdmin, upload.array("images", 8), csrfCheck, async (req, res, next) => {
try {
const id = await saveItem(req, null);
res.redirect(`/admin/items/${id}/edit`);
} catch (error) { next(error); }
});
app.post("/admin/items/:id/edit", requireAdmin, upload.array("images", 8), async (req, res, next) => {
app.post("/admin/items/:id/edit", requireAdmin, upload.array("images", 8), csrfCheck, async (req, res, next) => {
try {
await saveItem(req, req.params.id);
res.redirect(`/admin/items/${req.params.id}/edit`);
@@ -835,10 +895,10 @@ app.get("/admin/services/:id/edit", requireAdmin, (req, res) => {
const service = db.prepare("SELECT * FROM services WHERE id=?").get(req.params.id);
renderAdmin(req, res, `Edit ${service.name}`, "services", serviceEditor(service, res.locals.csrfToken));
});
app.post("/admin/services/new", requireAdmin, upload.single("image"), async (req, res, next) => {
app.post("/admin/services/new", requireAdmin, upload.single("image"), csrfCheck, async (req, res, next) => {
try { const id = await saveService(req, null); res.redirect(`/admin/services/${id}/edit`); } catch (e) { next(e); }
});
app.post("/admin/services/:id/edit", requireAdmin, upload.single("image"), async (req, res, next) => {
app.post("/admin/services/:id/edit", requireAdmin, upload.single("image"), csrfCheck, async (req, res, next) => {
try { await saveService(req, req.params.id); res.redirect(`/admin/services/${req.params.id}/edit`); } catch (e) { next(e); }
});
app.post("/admin/services/:id/duplicate", requireAdmin, (req, res) => {
@@ -950,7 +1010,7 @@ app.get("/admin/media", requireAdmin, (req, res) => {
<div class="table-list">${media.map((m) => `<article><h2>${escapeHtml(m.title || m.original_name)}</h2><p>${escapeHtml(m.url)} · ${bytes(m.size_bytes)}</p><form method="post" action="/admin/media/${m.id}">${csrf(res)}${input("Title", "title", m.title || "")}${input("Alt text", "alt_text", m.alt_text || "")}${input("Folder", "category", m.category || "")}<button>Save Image Details</button></form><form method="post" action="/admin/media/${m.id}/delete">${csrf(res)}<button class="danger">Delete Unused Image</button></form></article>`).join("")}</div>`;
renderAdmin(req, res, "Media Library", "media", body);
});
app.post("/admin/media", requireAdmin, upload.array("images", 10), async (req, res, next) => {
app.post("/admin/media", requireAdmin, upload.array("images", 10), csrfCheck, async (req, res, next) => {
try {
for (const file of req.files || []) await storeImage(file, req.body);
audit(req.admin.id, "Uploaded images", `${req.files?.length || 0} files`, req.ip);

View File

@@ -71,6 +71,32 @@ function safeJson(value, fallback = {}) {
}
}
function safeCssColor(value, fallback = "#000000") {
const text = String(value || "").trim();
if (/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(text)) return text;
if (/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(\s*,\s*(0|1|0?\.\d+))?\s*\)$/.test(text)) return text;
return fallback;
}
function safeCssNumber(value, fallback = 0, min = 0, max = 1) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.min(max, Math.max(min, number));
}
function safeCssKeyword(value, allowed, fallback) {
const text = String(value || "").trim().toLowerCase();
return allowed.includes(text) ? text : fallback;
}
function safeCssUrl(value) {
const text = String(value || "").trim();
if (!text) return "";
if (/^\/[A-Za-z0-9/_\-.%]+$/.test(text)) return text;
if (/^https:\/\/[^\s"'()<>]+$/i.test(text)) return text;
return "";
}
module.exports = {
escapeHtml,
cleanRichText,
@@ -80,5 +106,9 @@ module.exports = {
bytes,
checkbox,
listFromText,
safeJson
safeJson,
safeCssColor,
safeCssNumber,
safeCssKeyword,
safeCssUrl
};

View File

@@ -1 +0,0 @@
32184

View File

@@ -1,8 +1,23 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const request = require("supertest");
const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dws-test-"));
process.env.DATABASE_PATH = path.join(testRoot, "site.sqlite");
process.env.UPLOAD_DIR = path.join(testRoot, "uploads");
process.env.BACKUP_DIR = path.join(testRoot, "backups");
process.env.SESSION_SECRET = "test-session-secret-with-more-than-thirty-two-characters";
process.env.SETUP_TOKEN = "test-setup-token-with-enough-length";
const { app } = require("../src/server");
function csrfFrom(html) {
return /name="_csrf" value="([^"]+)"/.exec(html)?.[1] || "";
}
test("public pages load", async () => {
for (const path of ["/", "/items", "/services", "/about-contact", "/healthz"]) {
const response = await request(app).get(path);
@@ -15,10 +30,79 @@ test("admin redirects to setup or login", async () => {
assert.equal([302, 303].includes(response.status), true);
});
test("owner setup requires the private setup token", async () => {
const agent = request.agent(app);
const locked = await agent.get("/admin/setup");
assert.equal(locked.status, 403);
const setupPage = await agent.get("/admin/setup?token=test-setup-token-with-enough-length");
assert.equal(setupPage.status, 200);
const token = csrfFrom(setupPage.text);
const takeover = await agent
.post("/admin/setup")
.type("form")
.send({
_csrf: token,
name: "Attacker",
email: "attacker@example.com",
password: "attacker-password"
});
assert.equal(takeover.status, 403);
const created = await agent
.post("/admin/setup")
.type("form")
.send({
_csrf: token,
setup_token: "test-setup-token-with-enough-length",
name: "Owner",
email: "owner@example.com",
password: "owner-password-123"
});
assert.equal(created.status, 302);
assert.equal(created.headers.location, "/admin/dashboard");
const dashboard = await agent.get("/admin/dashboard");
assert.equal(dashboard.status, 200);
const setupAfterClaim = await agent.get("/admin/setup?token=test-setup-token-with-enough-length");
assert.equal(setupAfterClaim.status, 302);
assert.equal(setupAfterClaim.headers.location, "/admin/login");
});
test("multipart admin uploads pass CSRF after form parsing", async () => {
const agent = request.agent(app);
const loginPage = await agent.get("/admin/login");
const loginToken = csrfFrom(loginPage.text);
const login = await agent
.post("/admin/login")
.type("form")
.send({
_csrf: loginToken,
email: "owner@example.com",
password: "owner-password-123"
});
assert.equal(login.status, 302);
const mediaPage = await agent.get("/admin/media");
const uploadToken = csrfFrom(mediaPage.text);
const sampleImage = path.join(__dirname, "..", "public", "uploads", "sample-hero.png");
const upload = await agent
.post("/admin/media")
.field("_csrf", uploadToken)
.field("title", "Test upload")
.field("alt", "Test upload")
.attach("images", sampleImage);
assert.equal(upload.status, 302);
assert.equal(upload.headers.location, "/admin/media");
});
test("contact form requires complete fields", async () => {
const agent = request.agent(app);
const page = await agent.get("/about-contact");
const token = /name="_csrf" value="([^"]+)"/.exec(page.text)?.[1];
const token = csrfFrom(page.text);
const response = await agent
.post("/contact")
.send(`_csrf=${encodeURIComponent(token || "")}&name=&email=bad&subject=&message=`);