Harden deployment security
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal 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
|
||||
@@ -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
2
.gitignore
vendored
@@ -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
|
||||
|
||||
10
README.md
10
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.
|
||||
|
||||
@@ -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
19
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
32
src/utils.js
32
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
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
32184
|
||||
@@ -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=`);
|
||||
|
||||
Reference in New Issue
Block a user