diff --git a/src/constants/httpLimits.js b/src/constants/httpLimits.js index 2f7878d..273baca 100644 --- a/src/constants/httpLimits.js +++ b/src/constants/httpLimits.js @@ -2,7 +2,7 @@ const ALLOWED_HTTP_METHODS = ["HEAD", "GET", "POST"]; const MAX_HEADER_COUNT = 100; const DISALLOWED_CONTENT_TYPE_SUBSTRINGS = ["multipart/form-data"]; -const MAX_CONTENT_LENGTH = 4096; +const MAX_CONTENT_LENGTH = 5120; module.exports = { ALLOWED_HTTP_METHODS, diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js index 45881f7..d6931f5 100644 --- a/src/middleware/errorHandler.js +++ b/src/middleware/errorHandler.js @@ -34,6 +34,7 @@ query: req.query, body: req.body, ip: req.ip || req.connection?.remoteAddress, + metadata: err.metadata, }; if (req?.log?.error) { @@ -61,6 +62,15 @@ message, stack, errorContext, + + level: DEFAULT_LOG_LEVEL, + + method: req.method, + headers: req.headers, + query: req.query, + body: req.body, + ip: req.ip || req.connection?.remoteAddress, + metadata: err.metadata, }); const errorPageContext = await getBaseContext(req?.isAuthenticated, context); diff --git a/src/middleware/index.js b/src/middleware/index.js index 7ab9603..f963d06 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -37,37 +37,63 @@ app.disable("x-powered-by"); app.set("trust proxy", TRUST_PROXY); - // General parsers for non-excluded routes + // Body parsing with different limits for excluded vs normal paths app.use((req, res, next) => { - if (EXCLUDED_PATHS.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 - ); - }); - }); + const isExcludedPath = EXCLUDED_PATHS.includes(req.path); + const limit = isExcludedPath ? RAW_BODY_LIMIT_BYTES : DATA_LIMIT_BYTES; - // Raw parser + manual truncation for excluded routes - const rawBodyParser = express.raw({ - type: RAW_BODY_TYPE, - limit: RAW_BODY_LIMIT_BYTES, - }); - app.use((req, res, next) => { - if (!EXCLUDED_PATHS.includes(req.path)) return next(); - rawBodyParser(req, res, (err) => { - if (err) return next(err); - try { - const raw = req.body.toString(FALLBACK_ENCODING); - const truncated = raw.slice(0, DATA_LIMIT_BYTES); - req.body = JSON.parse(truncated); - } catch (e) { - req.body = FALLBACK_BODY; // Fallback on parse failure - } + console.log(`Processing ${req.method} ${req.path}`); + console.log(`Content-Type: ${req.get("content-type")}`); + console.log(`Is excluded path: ${isExcludedPath}, using limit: ${limit}`); + + const contentType = req.get("content-type") || ""; + + if (contentType.includes("application/json")) { + // Parse JSON with appropriate limit + express.json({ limit })(req, res, (err) => { + if (err) { + console.log("JSON parsing error:", err.message); + return next(err); + } + console.log("Parsed JSON body:", req.body); + next(); + }); + } else if (contentType.includes("application/x-www-form-urlencoded")) { + // Parse form data with appropriate limit + express.urlencoded({ extended: false, limit })(req, res, (err) => { + if (err) { + console.log("Form parsing error:", err.message); + return next(err); + } + console.log("Parsed form body:", req.body); + next(); + }); + } else if (contentType.includes("multipart/form-data")) { + // For multipart, we'd need multer or similar, but pass through for now + console.log("Multipart form detected - may need additional handling"); next(); - }); + } else { + // Try form parsing first (most common for HTML forms), then JSON + express.urlencoded({ extended: false, limit })(req, res, (formErr) => { + if (formErr) { + console.log("Form parsing failed, trying JSON:", formErr.message); + express.json({ limit })(req, res, (jsonErr) => { + if (jsonErr) { + console.log("Both parsers failed:", { + formErr: formErr.message, + jsonErr: jsonErr.message, + }); + return next(jsonErr); + } + console.log("Parsed JSON body (fallback):", req.body); + next(); + }); + } else { + console.log("Parsed form body (default):", req.body); + next(); + } + }); + } }); app.use(hbs); @@ -88,14 +114,13 @@ app.use(applyProductionSecurity); } - // app.use(express.json({ limit: "4kb" })); - // app.use(bodyParser.urlencoded({ extended: false, limit: "4kb" })); app.use(compression()); app.use(validateRequestIntegrity); app.use(formatHtml); app.use(redirectMiddleware); app.use(routes); app.use(errorHandler); + return app; } diff --git a/src/middleware/validateRequestIntegrity.js b/src/middleware/validateRequestIntegrity.js index 5972701..b376440 100644 --- a/src/middleware/validateRequestIntegrity.js +++ b/src/middleware/validateRequestIntegrity.js @@ -14,19 +14,33 @@ const headerCount = Object.keys(req.headers).length; if (!ALLOWED_HTTP_METHODS.includes(req.method)) { - return next(new HttpError(HTTP_ERRORS.METHOD_NOT_ALLOWED(req.method), 405)); + return next( + new HttpError(HTTP_ERRORS.METHOD_NOT_ALLOWED(req.method), 405, { + method: req.method, + }) + ); } if (contentLength > MAX_CONTENT_LENGTH) { - return next(new HttpError(HTTP_ERRORS.PAYLOAD_TOO_LARGE, 413)); + return next( + new HttpError(HTTP_ERRORS.PAYLOAD_TOO_LARGE, 413, { + payloadSize: contentLength, + }) + ); } if (DISALLOWED_CONTENT_TYPE_SUBSTRINGS.some((t) => contentType.includes(t))) { - return next(new HttpError(HTTP_ERRORS.FILE_UPLOADS_NOT_ALLOWED, 400)); + return next( + new HttpError(HTTP_ERRORS.FILE_UPLOADS_NOT_ALLOWED, 400, { + contentType: contentType, + }) + ); } if (headerCount > MAX_HEADER_COUNT) { - return next(new HttpError(HTTP_ERRORS.TOO_MANY_HEADERS, 400)); + return next( + new HttpError(HTTP_ERRORS.TOO_MANY_HEADERS, 400, { headerCount }) + ); } next(); diff --git a/src/routes/contact.js b/src/routes/contact.js index b8a68e6..feab0a0 100644 --- a/src/routes/contact.js +++ b/src/routes/contact.js @@ -54,35 +54,35 @@ analyzeThreatLevel, logSecurityEvent, } = require("../utils/securityForensics"); +const { validateAndSanitizeEmail } = require("../utils/emailValidator"); function isReasonableLength(str, maxLen) { return ( typeof str === "string" && str.trim().length > 0 && str.length <= maxLen ); } - router.post("/contact", formLimiter, async (req, res, next) => { try { - const { name, email, message, subject, hcaptchaToken, clientData } = - req.body; + const { name, email, message, subject, clientData } = req.body; + const hcaptchaToken = + req.body.hcaptchaToken || req.body["g-recaptcha-response"]; const emailResult = validateAndSanitizeEmail(email); if ( !emailResult.valid || !isReasonableLength(name, 100) || - !isValidEmail(email) || !isReasonableLength(subject, 150) || !isReasonableLength(message, 2000) ) { const invalidData = captureSecurityData(req, { formData: { name, email, subject, message }, - failureReason: "invalid_input", + failureReason: emailResult.message || "invalid_input", processingStep: "validation", }); await logSecurityEvent(invalidData, "validation_failure"); - return next(new HttpError("Invalid input", 400)); + return next(new HttpError("Invalid input", 400, invalidData)); } // Capture security data const securityData = captureSecurityData(req, { diff --git a/src/utils/HttpError.js b/src/utils/HttpError.js index a9443bb..b2de2f5 100644 --- a/src/utils/HttpError.js +++ b/src/utils/HttpError.js @@ -1,9 +1,9 @@ class HttpError extends Error { constructor(message, statusCode = 500, metadata = {}) { super(message); - this.name = 'HttpError'; + this.name = "HttpError"; this.statusCode = statusCode; - Object.assign(this, metadata); + Object.assign(this, { metadata }); Error.captureStackTrace(this, this.constructor); } } diff --git a/src/utils/buildErrorRenderContext.js b/src/utils/buildErrorRenderContext.js index cf74014..b95538e 100644 --- a/src/utils/buildErrorRenderContext.js +++ b/src/utils/buildErrorRenderContext.js @@ -1,22 +1,27 @@ +const util = require("util"); const { isProd } = require("./env"); -function buildErrorRenderContext({ - req, - requestId, - timestamp, - code, - statusCode, - message, - stack, - errorContext, -}) { +function buildErrorRenderContext(context = {}) { + const { + requestId, + timestamp, + code, + statusCode, + message, + stack, + errorContext = {}, + } = context; + + const { req, ...newContext } = context; + return { title: errorContext.title, message: isProd ? errorContext.message : message, content: isProd ? "" - : JSON.stringify( + : util.inspect( { + ...newContext, timestamp, requestId, method: req.method, @@ -24,13 +29,12 @@ code, statusCode, headers: req.headers, - query: req.query, + query: Object.assign({}, req.query), body: req.body, ip: req.ip || req.connection?.remoteAddress, stack, }, - null, - 2 + { depth: 4, colors: false } ), }; } diff --git a/src/utils/sendContactMail.js b/src/utils/sendContactMail.js index a213255..36a0165 100644 --- a/src/utils/sendContactMail.js +++ b/src/utils/sendContactMail.js @@ -1,9 +1,12 @@ const transporter = require("./transporter"); +const path = require("path"); +const fs = require("fs").promises; const { validateAndSanitizeEmail } = require("../utils/emailValidator"); const MAIL_DOMAIN = process.env.MAIL_DOMAIN; const MAIL_USER = process.env.MAIL_USER; const DEFAULT_SUBJECT = "New Contact Form Submission"; +const EMAIL_LOG_PATH = path.join(__dirname, "../../data/emails.json"); function sanitizeInput(input) { return String(input) @@ -13,7 +16,7 @@ const HttpError = require("./HttpError"); -function sendContactMail({ name, email, subject, message }) { +async function sendContactMail({ name, email, subject, message }) { const cleanName = sanitizeInput(name); const cleanSubject = sanitizeInput(subject || DEFAULT_SUBJECT); const cleanMessage = sanitizeInput(message); @@ -33,6 +36,21 @@ subject: cleanSubject, text: cleanMessage, }; + const emailLogEntry = { + timestamp: new Date().toISOString(), + name: cleanName, + email: sanitizedEmail, + subject: cleanSubject, + message: cleanMessage, + }; + try { + const data = await fs.readFile(EMAIL_LOG_PATH, "utf-8"); + const logs = JSON.parse(data); + logs.push(emailLogEntry); + await fs.writeFile(EMAIL_LOG_PATH, JSON.stringify(logs, null, 2)); + } catch (err) { + console.error("Failed to log email to file:", err); + } return transporter.sendMail(mailData); }