From 63c3a1b0f8beb597345d7352f47551e3e2bc06a3 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 21 Jul 2026 12:28:06 -0500 Subject: [PATCH] Harden deployment security --- .dockerignore | 11 +++++ .env.example | 7 ++++ .gitignore | 2 + README.md | 10 +++-- package.json | 3 +- pnpm-lock.yaml | 19 ++++++--- src/config.js | 22 ++++++++-- src/database.js | 4 +- src/render.js | 50 +++++++++++++++-------- src/server.js | 84 ++++++++++++++++++++++++++++++++------ src/utils.js | 32 ++++++++++++++- storage/server.pid | 1 - storage/server.stderr.log | 0 storage/server.stdout.log | 0 test/smoke.test.js | 86 ++++++++++++++++++++++++++++++++++++++- 15 files changed, 286 insertions(+), 45 deletions(-) create mode 100644 .dockerignore delete mode 100644 storage/server.pid delete mode 100644 storage/server.stderr.log delete mode 100644 storage/server.stdout.log diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e5f33d9 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index dac3c04..ed3fba9 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 1c537fa..67ae671 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 5483083..b86ebd7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json index 7d1af7f..5258f22 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb1433c..5c8bf00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/config.js b/src/config.js index e91a6e1..5a7c523 100644 --- a/src/config.js +++ b/src/config.js @@ -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 }; diff --git a/src/database.js b/src/database.js index 6a248dc..e4085ab 100644 --- a/src/database.js +++ b/src/database.js @@ -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.", diff --git a/src/render.js b/src/render.js index f83e45a..037f125 100644 --- a/src/render.js +++ b/src/render.js @@ -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 ? `${escapeHtml(section.image_alt || section.title || ` : ""; const content = `
diff --git a/src/server.js b/src/server.js index 51665cb..e9873d0 100644 --- a/src/server.js +++ b/src/server.js @@ -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", "

Owner setup requires the private setup link or setup token from the server administrator.

")); +} + 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 = ` -
+

${escapeHtml(setting(settings, "business.tagline", ""))}

${escapeHtml(setting(settings, "home.hero.title", "Debbie Windler Seamstress"))}

@@ -309,7 +367,7 @@ app.get("/", (req, res) => {
-
+

Showcase

${escapeHtml(setting(settings, "home.highlight.title", "Featured and Newly Added"))}

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

Create the first owner login. No password is stored as plain text.

+ @@ -494,7 +554,7 @@ app.get("/admin/setup", (req, res) => {
`)); }); -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) => {
${media.map((m) => `

${escapeHtml(m.title || m.original_name)}

${escapeHtml(m.url)} ยท ${bytes(m.size_bytes)}

${csrf(res)}${input("Title", "title", m.title || "")}${input("Alt text", "alt_text", m.alt_text || "")}${input("Folder", "category", m.category || "")}
${csrf(res)}
`).join("")}
`; 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); diff --git a/src/utils.js b/src/utils.js index 79dc8f7..5272da8 100644 --- a/src/utils.js +++ b/src/utils.js @@ -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 }; diff --git a/storage/server.pid b/storage/server.pid deleted file mode 100644 index ebf57c3..0000000 --- a/storage/server.pid +++ /dev/null @@ -1 +0,0 @@ -32184 diff --git a/storage/server.stderr.log b/storage/server.stderr.log deleted file mode 100644 index e69de29..0000000 diff --git a/storage/server.stdout.log b/storage/server.stdout.log deleted file mode 100644 index e69de29..0000000 diff --git a/test/smoke.test.js b/test/smoke.test.js index 76c920a..da7fe72 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -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=`);