diff --git a/src/middleware/authCheck.js b/src/middleware/authCheck.js new file mode 100644 index 0000000..4660673 --- /dev/null +++ b/src/middleware/authCheck.js @@ -0,0 +1,86 @@ +// middleware/authCheck.js +const fetch = require("node-fetch"); + +const VERIFY_URL = process.env.AUTH_VERIFY; +const CACHE_TTL = parseInt(process.env.AUTH_CACHE_TTL) || 120000; // 2 minutes default + +// Simple in-memory cache +const authCache = new Map(); + +// Helper to generate cache key +function getCacheKey(cookie, authHeader) { + return `${cookie}:${authHeader}`; +} + +// Helper to check if cache entry is valid +function isCacheValid(entry) { + return entry && Date.now() - entry.timestamp < CACHE_TTL; +} + +// Clean expired cache entries periodically +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of authCache.entries()) { + if (now - entry.timestamp >= CACHE_TTL) { + authCache.delete(key); + } + } +}, CACHE_TTL); // Clean up when entries would expire + +module.exports = async (req, res, next) => { + const cookie = req.headers["cookie"] || ""; + const authHeader = req.headers["authorization"] || ""; + const cacheKey = getCacheKey(cookie, authHeader); + + // Check cache first + const cached = authCache.get(cacheKey); + if (isCacheValid(cached)) { + req.isAuthenticated = cached.isAuthenticated; + return next(); + } + + // Default to unauthenticated + req.isAuthenticated = false; + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout + + const resVerify = await fetch(VERIFY_URL, { + headers: { + cookie, + authorization: authHeader, + }, + credentials: "include", + signal: controller.signal, + }); + + clearTimeout(timeout); + + const isAuthenticated = resVerify.status === 200; + + // Cache the result + authCache.set(cacheKey, { + isAuthenticated, + timestamp: Date.now(), + }); + + req.isAuthenticated = isAuthenticated; + } catch (err) { + // Auth server down/timeout - silently fail, don't crash the app + req.isAuthenticated = false; + + // Optional: Log for debugging, but don't spam logs + if (req.log) { + req.log.warn( + "[AuthCheck] Auth server unavailable, continuing unauthenticated" + ); + } else { + console.warn( + "[AuthCheck] Auth server unavailable, continuing unauthenticated" + ); + } + } + + next(); +}; diff --git a/src/middleware/authentication.js b/src/middleware/authentication.js deleted file mode 100644 index 3d4ae54..0000000 --- a/src/middleware/authentication.js +++ /dev/null @@ -1,27 +0,0 @@ -// middleware/authCheck.js -const fetch = require("node-fetch"); -const VERIFY_URL = process.env.AUTH_VERIFY; - -module.exports = async (req, res, next) => { - req.isAuthenticated = false; - return next(); - // const cookie = req.headers["cookie"] || ""; - // const authHeader = req.headers["authorization"] || ""; - - // try { - // const resVerify = await fetch(VERIFY_URL, { - // headers: { - // cookie, - // authorization: authHeader, - // }, - // credentials: "include", - // }); - - // req.isAuthenticated = resVerify.status === 200; - // } catch (err) { - // req.isAuthenticated = false; - // req.log.error("[AuthCheck] Fetch error:", err); - // } - - // next(); -}; diff --git a/src/middleware/baseContext.js b/src/middleware/baseContext.js index 03df026..e8cb659 100644 --- a/src/middleware/baseContext.js +++ b/src/middleware/baseContext.js @@ -3,7 +3,6 @@ module.exports = async function baseContextMiddleware(req, res, next) { const isAuthenticated = req.isAuthenticated; - console.log("test remote-user", req.isAuthenticated) const baseContext = await getBaseContext(isAuthenticated); res.locals.baseContext = baseContext; diff --git a/src/middleware/index.js b/src/middleware/index.js index 2cd52b9..ebce2ee 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -11,7 +11,7 @@ const errorHandler = require("./errorHandler"); const baseContext = require("./baseContext"); const hbs = require("./hbs"); -const authentication = require("./authentication.js"); +const authCheck = require("./authCheck"); const { loggingMiddleware, @@ -22,7 +22,7 @@ function setupApp() { const app = express(); - const excludedPaths = ['/contact', '/analytics', '/track']; + const excludedPaths = ["/contact", "/analytics", "/track"]; const DATA_LIMIT_BYTES = 10 * 1024; // 10k // General parsers for non-excluded routes @@ -30,18 +30,22 @@ 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); + 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' }); + 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 raw = req.body.toString("utf8"); const truncated = raw.slice(0, DATA_LIMIT_BYTES); req.body = JSON.parse(truncated); } catch (e) { @@ -51,17 +55,15 @@ }); }); - - app.use(hbs); // Setup logging app.use(logEvent, morganInfo, morganWarn, morganError, loggingMiddleware); - app.use(authentication); - + app.use(authCheck); + // Setup handlebars - app.use(baseContext) + app.use(baseContext); // Setup production environment if (process.env.NODE_ENV === "production") { diff --git a/src/routes/logs.js b/src/routes/logs.js index 8b80008..7fc4776 100644 --- a/src/routes/logs.js +++ b/src/routes/logs.js @@ -19,7 +19,11 @@ const db = new Database(dbPath, { readonly: true }); router.get("/logs", secured, (req, res) => { - res.render("pages/logs", { layout: "logs" }); + // res.render("pages/logs", { layout: "logs" }); + res.renderWithBaseContext("pages/logs", { + showSidebar: false, + showFooter: false, + }); }); router.post("/logs", secured, (req, res) => { diff --git a/src/routes/newsletter.js b/src/routes/newsletter.js index 0b3d72e..70f2156 100644 --- a/src/routes/newsletter.js +++ b/src/routes/newsletter.js @@ -4,8 +4,9 @@ const { saveEmail } = require("../services/newsletterService"); const formLimiter = require("../utils/formLimiter"); -const getBaseContext = require("../utils/baseContext"); const { qualifyLink } = require("../utils/qualifyLinks"); +const HttpError = require("../utils/HttpError"); +const { Http } = require("winston/lib/winston/transports"); router.get("/newsletter", async (req, res) => { const context = { @@ -26,14 +27,34 @@ router.post("/newsletter", formLimiter, async (req, res, next) => { const { email } = req.body; - if (!email) { - return res.status(400).send("Email is required"); + // Basic validation + if (!email || typeof email !== 'string') { + return next(new HttpError("Email is required", 400)) } + // Sanitize and validate email + const sanitizedEmail = validator.normalizeEmail(email.trim()); + if (!sanitizedEmail || !validator.isEmail(sanitizedEmail)) { + return next(new HttpError("Invalid email format", 400)) + } + + // Length check + if (sanitizedEmail.length > 320) { // RFC 5321 limit + return next(new HttpError("Email too long", 400)) + } + try { - saveEmail(email); + await saveEmail(email); await sendNewsletterSubscriptionMail({ email }); res.redirect("/newsletter/success"); } catch (err) { + // Log the error but don't expose internal details + console.error('Newsletter subscription error:', err); + + // Generic response to avoid information disclosure + if (err.code === 'DUPLICATE_EMAIL') { + // Still redirect to success to avoid enumeration + return res.redirect("/newsletter/success"); + } next(err); } }); diff --git a/src/utils/baseContext.js b/src/utils/baseContext.js index 46653e9..f2875e5 100644 --- a/src/utils/baseContext.js +++ b/src/utils/baseContext.js @@ -7,19 +7,27 @@ const navLinks = require(path.join(__dirname, "../../content/navLinks.json")); const filterSecureLinks = require("../utils/filterSecureLinks"); -module.exports = async function getBaseContext(isAuthenticated, overrides = {}) { +module.exports = async function getBaseContext( + isAuthenticated, + overrides = {} +) { const filteredNavLinks = filterSecureLinks(navLinks, isAuthenticated); const qualifiedNavLinks = qualifyNavLinks(filteredNavLinks); const menu = await getPostsMenu(path.join(__dirname, "../../content/posts")); - return Object.assign({ - siteOwner: process.env.SITE_OWNER, - originCountry: process.env.COUNTRY, - hCaptchaKey: process.env.HCAPTCHA_KEY, - navLinks: qualifiedNavLinks, - years: menu, - formatMonth, - baseUrl, - isAuthenticated - }, overrides); + return Object.assign( + { + siteOwner: process.env.SITE_OWNER, + originCountry: process.env.COUNTRY, + hCaptchaKey: process.env.HCAPTCHA_KEY, + navLinks: qualifiedNavLinks, + years: menu, + formatMonth, + baseUrl, + isAuthenticated, + showSidebar: true, + showFooter: true, + }, + overrides + ); }; diff --git a/src/utils/validateEmail.js b/src/utils/validateEmail.js new file mode 100644 index 0000000..5ae0b4f --- /dev/null +++ b/src/utils/validateEmail.js @@ -0,0 +1,30 @@ +const validator = require("validator"); + +// Email validation function +const validateEmail = (email) => { + if (!email || typeof email !== 'string') { + return { valid: false, message: "Email is required" }; + } + + // Trim and normalize + email = email.trim().toLowerCase(); + + // Length check + if (email.length > 254) { + return { valid: false, message: "Email address is too long" }; + } + + // Basic validation + if (!validator.isEmail(email)) { + return { valid: false, message: "Please enter a valid email address" }; + } + + // Additional checks for suspicious patterns + if (email.includes('..') || email.startsWith('.') || email.endsWith('.')) { + return { valid: false, message: "Please enter a valid email address" }; + } + + return { valid: true, email }; +}; + +module.exports = validateEmail; diff --git a/src/views/layouts/main.handlebars b/src/views/layouts/main.handlebars index a7c27f3..0f3d434 100644 --- a/src/views/layouts/main.handlebars +++ b/src/views/layouts/main.handlebars @@ -16,18 +16,22 @@
{{> headers}}