Harden deployment security
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user