diff --git a/src/app.js b/src/app.js index 936bff1..4e31252 100644 --- a/src/app.js +++ b/src/app.js @@ -1,4 +1,5 @@ // src/app.js +console.log('CWD:', process.cwd()); require("dotenv").config(); const setupMiddleware = require("./middleware"); diff --git a/src/middleware/applyProductionSecurity.js b/src/middleware/applyProductionSecurity.js index 2f53ac0..19c3a8f 100644 --- a/src/middleware/applyProductionSecurity.js +++ b/src/middleware/applyProductionSecurity.js @@ -1,55 +1,50 @@ const helmet = require("helmet"); const hpp = require("hpp"); const xssSanitizer = require("./xssSanitizer"); +const HttpError = require("../utils/HttpError"); +const { baseUrl } = require("../utils/baseUrl"); -function applyProductionSecurity(app) { - app.disable("x-powered-by"); - app.set("trust proxy", true); - - app.use((req, res, next) => { +const applyProductionSecurity = [ + (req, res, next) => { + req.app.disable("x-powered-by"); + req.app.set("trust proxy", true); + next(); + }, + (req, res, next) => { const forwardedIp = req.ip; const directIp = req.connection.remoteAddress; - if (req.log?.info) { - req.log.info(`Forwarded IP: ${forwardedIp}`); - req.log.info(`Direct IP: ${directIp}`); - } + req.log?.info?.(`Forwarded IP: ${forwardedIp}`); + req.log?.info?.(`Direct IP: ${directIp}`); next(); - }); - app.use(hpp()); - app.use(xssSanitizer); - // app.use(rateLimit({ windowMs: 1 * 60 * 1000, max: 100 })); - app.use((req, res, next) => { + }, + hpp(), + xssSanitizer, + // rateLimit middleware can be added here + (req, res, next) => { const host = req.hostname; if (["127.0.0.1", "localhost"].includes(host)) { - const err = new Error("Forbidden"); - err.statusCode = 403; - return next(err); + return next(new HttpError("Forbidden", 403)); } next(); - }); - app.use(helmet.hsts({ maxAge: 63072000 })); - - app.use( - helmet.contentSecurityPolicy({ - directives: { - defaultSrc: ["'self'"], - scriptSrc: ["'self'", "https://hcaptcha.com"], - styleSrc: ["'self'", "https:"], - imgSrc: [ - "'self'", - "data:", - "https://licensebuttons.net", - "https://cdn.jsdelivr.net", - ], - frameSrc: ["'self'", "https://newassets.hcaptcha.com"], - objectSrc: ["'none'"], - upgradeInsecureRequests: [], - - // add other directives as needed - }, - }) - ); // Sets secure HTTP headers. Prevents common attacks. -} + }, + helmet.hsts({ maxAge: 63072000 }), + helmet.contentSecurityPolicy({ + directives: { + defaultSrc: ["'self'", baseUrl], + scriptSrc: ["'self'", "https://hcaptcha.com"], + styleSrc: ["'self'", "https:"], + imgSrc: [ + "'self'", + "data:", + "https://licensebuttons.net", + "https://cdn.jsdelivr.net", + ], + frameSrc: ["'self'", "https://newassets.hcaptcha.com"], + objectSrc: ["'none'"], + upgradeInsecureRequests: [], + }, + }), +]; module.exports = applyProductionSecurity; diff --git a/src/middleware/hbs.js b/src/middleware/hbs.js new file mode 100644 index 0000000..47dcb3b --- /dev/null +++ b/src/middleware/hbs.js @@ -0,0 +1,35 @@ +// src/middleware/hbs.js +const path = require("path"); +const exphbs = require("express-handlebars"); +const { registerHelpers } = require("../utils/hbsHelpers"); + +const hbsMiddleware = (req, res, next) => { + if (!req.app.get("view engine")) { + const hbs = exphbs.create({ + layoutsDir: path.join(__dirname, "../views/layouts"), + partialsDir: path.join(__dirname, "../views/partials"), + defaultLayout: "main", + helpers: { + section(name, options) { + this._sections ??= {}; + this._sections[name] = options.fn(this); + return null; + }, + }, + extname: ".handlebars", + runtimeOptions: { + allowProtoPropertiesByDefault: true, + allowProtoMethodsByDefault: true, + }, + }); + + registerHelpers(hbs); + req.app.engine("handlebars", hbs.engine); + req.app.set("view engine", "handlebars"); + req.app.set("views", path.join(__dirname, "../views")); + } + + next(); +}; + +module.exports = hbsMiddleware; diff --git a/src/middleware/index.js b/src/middleware/index.js index 3ebfe92..ab072f1 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -1,7 +1,5 @@ // src/setupMiddleware.js const express = require("express"); -const path = require("path"); -const exphbs = require("express-handlebars"); const bodyParser = require("body-parser"); const compression = require("compression"); @@ -10,8 +8,8 @@ const logEvent = require("./analytics.js"); const applyProductionSecurity = require("./applyProductionSecurity"); const validateRequestIntegrity = require("./validateRequestIntegrity"); -const { registerHelpers } = require("../utils/hbsHelpers"); const errorHandler = require("./errorHandler"); +const hbs = require("./hbs"); const { loggingMiddleware, @@ -22,40 +20,50 @@ function setupApp() { const app = express(); + + const excludedPaths = ['/contact', '/analytics', '/track']; + const DATA_LIMIT_BYTES = 10 * 1024; // 10k + + // General parsers for non-excluded routes + app.use((req, res, next) => { + if (excludedPaths.includes(req.path)) return next(); + express.json({ limit: DATA_LIMIT_BYTES })(req, res, (err) => { + if (err) return next(err); + express.urlencoded({ extended: false, limit: DATA_LIMIT_BYTES })(req, res, next); + }); + }); + + // Raw parser + manual truncation for excluded routes + const rawBodyParser = express.raw({ type: '*/*', limit: '100kb' }); + app.use((req, res, next) => { + if (!excludedPaths.includes(req.path)) return next(); + rawBodyParser(req, res, (err) => { + if (err) return next(err); + try { + const raw = req.body.toString('utf8'); + const truncated = raw.slice(0, DATA_LIMIT_BYTES); + req.body = JSON.parse(truncated); + } catch (e) { + req.body = {}; // Fallback on parse failure + } + next(); + }); + }); + + // Setup logging app.use(logEvent, morganInfo, morganWarn, morganError, loggingMiddleware); - // Setup view engine - - const hbs = exphbs.create({ - layoutsDir: path.join(__dirname, "../views/layouts"), - partialsDir: path.join(__dirname, "../views/partials"), - defaultLayout: "main", - helpers: { - section: function (name, options) { - this._sections ??= {}; - this._sections[name] = (this._sections[name] || '') + options.fn(this); - return null; - }, - }, - extname: ".handlebars", - runtimeOptions: { - allowProtoPropertiesByDefault: true, - allowProtoMethodsByDefault: true, - }, - }); - registerHelpers(hbs); - app.engine("handlebars", hbs.engine); - app.set("view engine", "handlebars"); - app.set("views", path.join(__dirname, "../views")); + // Setup handlebars + app.use(hbs); // Setup production environment if (process.env.NODE_ENV === "production") { - applyProductionSecurity(app); + app.use(applyProductionSecurity); } - app.use(express.json({ limit: "4kb" })); - app.use(bodyParser.urlencoded({ extended: false, limit: "4kb" })); + // app.use(express.json({ limit: "4kb" })); + // app.use(bodyParser.urlencoded({ extended: false, limit: "4kb" })); app.use(compression()); app.use(validateRequestIntegrity); app.use(formatHtml); diff --git a/src/middleware/validateRequestIntegrity.js b/src/middleware/validateRequestIntegrity.js index ed05f72..a40eb70 100644 --- a/src/middleware/validateRequestIntegrity.js +++ b/src/middleware/validateRequestIntegrity.js @@ -1,3 +1,4 @@ +const HttpError = require("../utils/HttpError") module.exports = (req, res, next) => { const allowedMethods = ["GET", "POST"]; const contentLength = parseInt(req.get("content-length") || "0", 10); @@ -6,27 +7,25 @@ if (!allowedMethods.includes(req.method)) { return next( - Object.assign(new Error("Method Not Allowed"), { statusCode: 405 }) + new HttpError("Method Not Allowed", 405) ); } if (contentLength > 4096) { return next( - Object.assign(new Error("Payload Too Large"), { statusCode: 413 }) + new HttpError("Payload Too Large", 413) ); } if (contentType.includes("multipart/form-data")) { return next( - Object.assign(new Error("File uploads are not allowed."), { - statusCode: 400, - }) + new HttpError("File uploads are not allowed.", 400) ); } if (headerCount > 100) { return next( - Object.assign(new Error("Too many headers."), { statusCode: 400 }) + new HttpError("Too many headers.", 400) ); } diff --git a/src/routes/contact.js b/src/routes/contact.js index de6d90e..bb317e5 100644 --- a/src/routes/contact.js +++ b/src/routes/contact.js @@ -49,6 +49,7 @@ const crypto = require("crypto"); const fs = require("fs").promises; const path = require("path"); +const HttpError = require("../utils/HttpError") // Threat detection patterns const THREAT_PATTERNS = { @@ -244,7 +245,30 @@ router.post("/contact", formLimiter, async (req, res, next) => { try { const { name, email, message, subject, hcaptchaToken, clientData } = req.body; - + // Basic input validation + function isValidEmail(email) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254; + } + + function isReasonableLength(str, maxLen) { + return typeof str === 'string' && str.trim().length > 0 && str.length <= maxLen; + } + + if ( + !isReasonableLength(name, 100) || + !isValidEmail(email) || + !isReasonableLength(subject, 150) || + !isReasonableLength(message, 2000) + ) { + const invalidData = captureSecurityData(req, { + formData: { name, email, subject, message }, + failureReason: 'invalid_input', + processingStep: 'validation' + }); + + await logSecurityEvent(invalidData, 'validation_failure'); + return next(new HttpError("Invalid input", 400)); + } // Capture security data const securityData = captureSecurityData(req, { formData: { name, email, message, subject }, @@ -275,7 +299,7 @@ failureReason: 'missing_captcha' }, 'validation_failure'); - return res.status(400).send("Captcha token missing"); + return next(new HttpError("Captcha token missing", 400)); } const valid = await verifyHCaptcha(hcaptchaToken); @@ -287,7 +311,7 @@ failureReason: 'captcha_failed' }, 'validation_failure'); - return res.status(400).send("Captcha verification failed"); + return next(new HttpError("Captcha verification failed", 400)); } // High threat handling diff --git a/src/routes/index.js b/src/routes/index.js index fddaf93..d44f5f0 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -25,6 +25,8 @@ } router.post("/track", analytics); +router.post("/analytics", analytics); + router.use( "/static", express.static("public", { @@ -53,9 +55,7 @@ }); router.use((req, res, next) => { - const err = new Error(); - err.statusCode = 404; - next(err); + next(new HttpError(null, 404)); }); module.exports = router; diff --git a/src/routes/post.js b/src/routes/post.js index 8e95b87..aeb958f 100644 --- a/src/routes/post.js +++ b/src/routes/post.js @@ -5,29 +5,24 @@ const matter = require("gray-matter"); const getBaseContext = require("../utils/baseContext"); +const HttpError = require("../utils/HttpError") module.exports = async (req, res, next) => { const { year, month, name } = req.params; // Validate year: 4 digits only if (!/^\d{4}$/.test(year)) { - const error = new Error("Invalid year parameter."); - error.statusCode = 400; - return next(error); + return next(new HttpError("Invalid year parameter.", 400)); } // Validate month: 01-12 only if (!/^(0[1-9]|1[0-2])$/.test(month)) { - const error = new Error("Invalid month parameter."); - error.statusCode = 400; - return next(error); + return next(new HttpError("Invalid month parameter.", 400)); } // Validate name: allow alphanumeric, dash, underscore only (no dots, no slashes) if (!/^[a-zA-Z0-9_-]+$/.test(name)) { - const error = new Error("Invalid post name parameter."); - error.statusCode = 400; - return next(error); + return next(new HttpError("Invalid post name parameter.", 400)); } const mdPath = path.join( @@ -53,8 +48,6 @@ }); res.render("pages/post", context); } catch (err) { - const error = new Error("The requested blog post could not be found."); - error.statusCode = 404; - next(error); + next(new HttpError("The requested blog post could not be found.", 404)); } }; diff --git a/src/routes/sitemap.js b/src/routes/sitemap.js index 61acad1..bd2c356 100644 --- a/src/routes/sitemap.js +++ b/src/routes/sitemap.js @@ -6,6 +6,8 @@ const Handlebars = require("handlebars"); const getBaseContext = require("../utils/baseContext"); const sitemapService = require("../services/sitemapService"); +const { qualifyLink } = require("../utils/qualifyLinks.js"); +const { baseUrl } = require("../utils/baseUrl"); // Precompile XML template once const xmlTplSrc = fs.readFileSync( @@ -31,14 +33,15 @@ res.render("pages/sitemap", context); }); +const getBaseUrl = require("../utils/baseUrl"); // XML sitemap endpoint router.get("/sitemap.xml", async (req, res) => { const urls = await sitemapService.getAllUrls(); - const baseUrl = `${req.protocol}://${req.get("host")}`; + // const baseUrl = getBaseUrl({ protocol: req.protocol, host: req.get("host") }); // Format URLs for XML template const formattedUrls = urls.map((url) => ({ - loc: `${baseUrl}${url.loc}`, + loc: qualifyLink(url.loc), lastmod: url.lastmod, changefreq: url.changefreq, priority: url.priority, @@ -48,4 +51,5 @@ res.type("application/xml").send(xml); }); + module.exports = router; diff --git a/src/services/postsMenuService.js b/src/services/postsMenuService.js index dfcf0cb..3ccad2d 100644 --- a/src/services/postsMenuService.js +++ b/src/services/postsMenuService.js @@ -1,5 +1,6 @@ // src/services/postsMenuService.js (refactored) const { getAllPosts } = require("../utils/postFileUtils"); +const { qualifyLink } = require("../utils/qualifyLinks"); async function getPostsMenu(baseDir) { const allPosts = await getAllPosts(baseDir); @@ -19,7 +20,7 @@ } monthMap.get(post.month).push({ - url: post.url, + url: qualifyLink(post.url), slug: post.slug, title: post.title, date: post.date, diff --git a/src/utils/HttpError.js b/src/utils/HttpError.js new file mode 100644 index 0000000..a9443bb --- /dev/null +++ b/src/utils/HttpError.js @@ -0,0 +1,10 @@ +class HttpError extends Error { + constructor(message, statusCode = 500, metadata = {}) { + super(message); + this.name = 'HttpError'; + this.statusCode = statusCode; + Object.assign(this, metadata); + Error.captureStackTrace(this, this.constructor); + } +} +module.exports = HttpError; diff --git a/src/utils/baseContext.js b/src/utils/baseContext.js index 9b8521f..e57f45a 100644 --- a/src/utils/baseContext.js +++ b/src/utils/baseContext.js @@ -1,9 +1,13 @@ // src/utils/baseContext.js const path = require("path"); const getPostsMenu = require("../services/postsMenuService"); -const { formatMonth } = require("../utils/formatMonth"); +const { formatMonth } = require("./formatMonth"); +const { qualifyNavLinks } = require("./qualifyLinks.js"); +const navLinks = require(path.join(__dirname, "../../content/navLinks.json")); async function getBaseContext(overrides = {}) { + + const qualifiedNavLinks = qualifyNavLinks(navLinks); const menu = await getPostsMenu(path.join(__dirname, "../../content/posts")); return Object.assign( @@ -11,21 +15,7 @@ siteOwner: process.env.SITE_OWNER, originCountry: process.env.COUNTRY, hCaptchaKey: process.env.HCAPTCHA_KEY, - navLinks: [ - { href: "/", label: "Home" }, - { - // href: "/about", - label: "About", - submenu: [ - { href: "/about/me", label: "About Me" }, - { href: "/about/blog", label: "About This Blog" }, - ], - }, - { href: "/newsletter", label: "Newsletter" }, - { href: "/tools", label: "Tools I use" }, - { href: "/projects", label: "Projects" }, - { href: "/contact", label: "Contact" }, - ], + navLinks: qualifiedNavLinks, years: menu, formatMonth, }, diff --git a/src/utils/baseUrl.js b/src/utils/baseUrl.js new file mode 100644 index 0000000..3b102c7 --- /dev/null +++ b/src/utils/baseUrl.js @@ -0,0 +1,15 @@ +// src/utils/baseUrl.js +function getBaseUrl({ protocol = null, host = null } = {}) { + const envProtocol = process.env.PROTOCOL; + const envDomain = process.env.DOMAIN; + + const finalProtocol = envProtocol || protocol || "https"; + const finalDomain = (envDomain || host || "localhost") + .replace(/^https?:\/\//, "") + .replace(/\/$/, ""); + + return `${finalProtocol}://${finalDomain}`; +} +const baseUrl = getBaseUrl(); + +module.exports = { baseUrl, getBaseUrl }; diff --git a/src/utils/logging.js b/src/utils/logging.js index 3b81394..a7846ec 100644 --- a/src/utils/logging.js +++ b/src/utils/logging.js @@ -162,7 +162,21 @@ buildTransport("notice", "notice"), new transports.Console({ level: "debug", - format: format.combine(format.colorize(), format.simple()), + format: format.combine( + format.colorize(), + format.timestamp(), + format.printf(({ timestamp, level, message, ...meta }) => { + let stack = meta.stack || ""; + if (stack) delete meta.stack; + + let metaString = ""; + if (Object.keys(meta).length > 0) { + metaString = JSON.stringify(meta, null, 2); + } + + return `[${timestamp}] [${level}] ${message}\n${stack}\n${metaString}`; + }) + ), }), // new transports.Console({ // level: "debug", // or "warn"/"error" diff --git a/src/utils/qualifyLinks.js b/src/utils/qualifyLinks.js new file mode 100644 index 0000000..3b5dcd2 --- /dev/null +++ b/src/utils/qualifyLinks.js @@ -0,0 +1,25 @@ +const { baseUrl } = require("../utils/baseUrl"); +// const baseUrl = getBaseUrl({ protocol: req.protocol, host: req.get("host") }); + +function qualifyLink(href) { + if (!href) return href; + // Return unchanged if href is absolute URL or protocol-relative + if (/^(?:[a-zA-Z][a-zA-Z\d+\-.]*:)?\/\//.test(href)) return href; + // Prefix with baseUrl if relative + return baseUrl + href; +} + +function qualifyNavLinks(links) { + return links.map(link => { + const qualified = { ...link }; + if (qualified.href) { + qualified.href = qualifyLink(qualified.href); + } + if (qualified.submenu) { + qualified.submenu = qualifyNavLinks(qualified.submenu); + } + return qualified; + }); +} + +module.exports = { qualifyNavLinks, qualifyLink } diff --git a/src/utils/sendContactMail.js b/src/utils/sendContactMail.js index 0fca3f0..6b53e2a 100644 --- a/src/utils/sendContactMail.js +++ b/src/utils/sendContactMail.js @@ -13,7 +13,7 @@ } function sendContactMail({ name, email, subject, message }) { - const { DOMAIN: domain } = process.env; + const { MAIL_DOMAIN: domain } = process.env; // Sanitize inputs const cleanName = sanitizeInput(name); diff --git a/src/utils/sendNewsletterSubscriptionMail.js b/src/utils/sendNewsletterSubscriptionMail.js index cff35fc..c8fae17 100644 --- a/src/utils/sendNewsletterSubscriptionMail.js +++ b/src/utils/sendNewsletterSubscriptionMail.js @@ -1,7 +1,7 @@ // src/utils/sendNewsletterSubscriptionMail.js const transporter = require("./transporter"); const sendNewsletterSubscriptionMail = async function ({ email }) { - const { DOMAIN: domain } = process.env; + const { MAIL_DOMAIN: domain } = process.env; const data = { from: `"Newsletter" `, to: email, diff --git a/src/views/layouts/sitemap-xsl.handlebars b/src/views/layouts/sitemap-xsl.handlebars new file mode 100644 index 0000000..e9bdb47 --- /dev/null +++ b/src/views/layouts/sitemap-xsl.handlebars @@ -0,0 +1,46 @@ + + + + + + + + + + Sitemap + + + + + + + + + + + + + + + + + + + + + + + + +
Site Map
URLLast ModifiedChange FrequencyPriority
+ + + +
+ + + +
+ +
diff --git a/src/views/pages/sitemap-xml.handlebars b/src/views/pages/sitemap-xml.handlebars index 1af270a..34e9266 100644 --- a/src/views/pages/sitemap-xml.handlebars +++ b/src/views/pages/sitemap-xml.handlebars @@ -1,5 +1,5 @@ - + {{#each urls}}