From a9fa579b6d15e35a1717e10531fc1e238483a0d0 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Tue, 16 Sep 2025 12:27:15 -0400 Subject: [PATCH] protect against sql injection, xss, csrf --- backend/middleware/csrf.js | 83 +++ backend/middleware/validation.js | 271 +++++++ backend/models/User.js | 49 +- backend/package-lock.json | 687 +++++++++++++++++- backend/package.json | 6 + backend/routes/auth.js | 49 +- backend/server.js | 55 +- frontend/src/components/AuthModal.tsx | 4 + frontend/src/components/LocationMap.tsx | 83 --- .../src/components/PasswordStrengthMeter.tsx | 127 ++++ 10 files changed, 1311 insertions(+), 103 deletions(-) create mode 100644 backend/middleware/csrf.js create mode 100644 backend/middleware/validation.js delete mode 100644 frontend/src/components/LocationMap.tsx create mode 100644 frontend/src/components/PasswordStrengthMeter.tsx diff --git a/backend/middleware/csrf.js b/backend/middleware/csrf.js new file mode 100644 index 0000000..20e2072 --- /dev/null +++ b/backend/middleware/csrf.js @@ -0,0 +1,83 @@ +const csrf = require("csrf"); +const cookieParser = require("cookie-parser"); + +// Initialize CSRF token generator +const tokens = new csrf(); + +// Generate a secret for signing tokens +const secret = tokens.secretSync(); + +// CSRF middleware using double submit cookie pattern +const csrfProtection = (req, res, next) => { + // Skip CSRF for safe methods + if (["GET", "HEAD", "OPTIONS"].includes(req.method)) { + return next(); + } + + // Get token from header or body + const token = + req.headers["x-csrf-token"] || req.body.csrfToken || req.query.csrfToken; + + // Get token from cookie + const cookieToken = req.cookies["csrf-token"]; + + // Verify both tokens exist and match + if (!token || !cookieToken || token !== cookieToken) { + return res.status(403).json({ + error: "Invalid CSRF token", + code: "CSRF_TOKEN_MISMATCH", + }); + } + + // Verify token is valid + if (!tokens.verify(secret, token)) { + return res.status(403).json({ + error: "Invalid CSRF token", + code: "CSRF_TOKEN_INVALID", + }); + } + + next(); +}; + +// Middleware to generate and send CSRF token +const generateCSRFToken = (req, res, next) => { + const token = tokens.create(secret); + + // Set token in cookie (httpOnly for security) + res.cookie("csrf-token", token, { + httpOnly: true, + secure: process.env.NODE_ENV !== "dev", + sameSite: "strict", + maxAge: 60 * 60 * 1000, // 1 hour + }); + + // Also provide token in header for client to use + res.set("X-CSRF-Token", token); + + // Make token available to response + res.locals.csrfToken = token; + + next(); +}; + +// Route to get CSRF token (for initial page loads) +const getCSRFToken = (req, res) => { + const token = tokens.create(secret); + + res.cookie("csrf-token", token, { + httpOnly: true, + secure: process.env.NODE_ENV !== "dev", + sameSite: "strict", + maxAge: 60 * 60 * 1000, + }); + + res.json({ csrfToken: token }); +}; + +module.exports = { + csrfProtection, + generateCSRFToken, + getCSRFToken, + cookieParser: cookieParser(), +}; diff --git a/backend/middleware/validation.js b/backend/middleware/validation.js new file mode 100644 index 0000000..1179197 --- /dev/null +++ b/backend/middleware/validation.js @@ -0,0 +1,271 @@ +const { body, validationResult } = require("express-validator"); +const DOMPurify = require("dompurify"); +const { JSDOM } = require("jsdom"); + +// Create a DOM purify instance for server-side sanitization +const window = new JSDOM("").window; +const purify = DOMPurify(window); + +// Password strength validation +const passwordStrengthRegex = + /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{8,}$/; +const commonPasswords = [ + "password", + "123456", + "123456789", + "qwerty", + "abc123", + "password123", + "admin", + "letmein", + "welcome", + "monkey", + "1234567890", +]; + +// Sanitization middleware +const sanitizeInput = (req, res, next) => { + const sanitizeValue = (value) => { + if (typeof value === "string") { + return purify.sanitize(value, { ALLOWED_TAGS: [] }); + } + if (typeof value === "object" && value !== null) { + const sanitized = {}; + for (const [key, val] of Object.entries(value)) { + sanitized[key] = sanitizeValue(val); + } + return sanitized; + } + return value; + }; + + if (req.body) { + req.body = sanitizeValue(req.body); + } + if (req.query) { + req.query = sanitizeValue(req.query); + } + if (req.params) { + req.params = sanitizeValue(req.params); + } + + next(); +}; + +// Validation error handler +const handleValidationErrors = (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + error: "Validation failed", + details: errors.array().map((err) => ({ + field: err.path, + message: err.msg, + })), + }); + } + next(); +}; + +// Registration validation rules +const validateRegistration = [ + body("email") + .isEmail() + .normalizeEmail() + .withMessage("Please provide a valid email address") + .isLength({ max: 255 }) + .withMessage("Email must be less than 255 characters"), + + body("password") + .isLength({ min: 8, max: 128 }) + .withMessage("Password must be between 8 and 128 characters") + .matches(passwordStrengthRegex) + .withMessage( + "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character" + ) + .custom((value) => { + if (commonPasswords.includes(value.toLowerCase())) { + throw new Error( + "Password is too common. Please choose a stronger password" + ); + } + return true; + }), + + body("firstName") + .trim() + .isLength({ min: 1, max: 50 }) + .withMessage("First name must be between 1 and 50 characters") + .matches(/^[a-zA-Z\s\-']+$/) + .withMessage( + "First name can only contain letters, spaces, hyphens, and apostrophes" + ), + + body("lastName") + .trim() + .isLength({ min: 1, max: 50 }) + .withMessage("Last name must be between 1 and 50 characters") + .matches(/^[a-zA-Z\s\-']+$/) + .withMessage( + "Last name can only contain letters, spaces, hyphens, and apostrophes" + ), + + body("username") + .optional() + .trim() + .isLength({ min: 3, max: 30 }) + .withMessage("Username must be between 3 and 30 characters") + .matches(/^[a-zA-Z0-9_-]+$/) + .withMessage( + "Username can only contain letters, numbers, underscores, and hyphens" + ), + + body("phone") + .optional() + .isMobilePhone() + .withMessage("Please provide a valid phone number"), + + handleValidationErrors, +]; + +// Login validation rules +const validateLogin = [ + body("email") + .isEmail() + .normalizeEmail() + .withMessage("Please provide a valid email address"), + + body("password") + .notEmpty() + .withMessage("Password is required") + .isLength({ max: 128 }) + .withMessage("Password is too long"), + + handleValidationErrors, +]; + +// Google auth validation +const validateGoogleAuth = [ + body("idToken") + .notEmpty() + .withMessage("Google ID token is required") + .isLength({ max: 2048 }) + .withMessage("Invalid token format"), + + handleValidationErrors, +]; + +// Profile update validation +const validateProfileUpdate = [ + body("firstName") + .optional() + .trim() + .isLength({ min: 1, max: 50 }) + .withMessage("First name must be between 1 and 50 characters") + .matches(/^[a-zA-Z\s\-']+$/) + .withMessage( + "First name can only contain letters, spaces, hyphens, and apostrophes" + ), + + body("lastName") + .optional() + .trim() + .isLength({ min: 1, max: 50 }) + .withMessage("Last name must be between 1 and 50 characters") + .matches(/^[a-zA-Z\s\-']+$/) + .withMessage( + "Last name can only contain letters, spaces, hyphens, and apostrophes" + ), + + body("phone") + .optional() + .isMobilePhone() + .withMessage("Please provide a valid phone number"), + + body("address1") + .optional() + .trim() + .isLength({ max: 255 }) + .withMessage("Address line 1 must be less than 255 characters"), + + body("address2") + .optional() + .trim() + .isLength({ max: 255 }) + .withMessage("Address line 2 must be less than 255 characters"), + + body("city") + .optional() + .trim() + .isLength({ max: 100 }) + .withMessage("City must be less than 100 characters") + .matches(/^[a-zA-Z\s\-']+$/) + .withMessage( + "City can only contain letters, spaces, hyphens, and apostrophes" + ), + + body("state") + .optional() + .trim() + .isLength({ max: 100 }) + .withMessage("State must be less than 100 characters"), + + body("zipCode") + .optional() + .trim() + .matches(/^[0-9]{5}(-[0-9]{4})?$/) + .withMessage("Please provide a valid ZIP code"), + + body("country") + .optional() + .trim() + .isLength({ max: 100 }) + .withMessage("Country must be less than 100 characters"), + + handleValidationErrors, +]; + +// Password change validation +const validatePasswordChange = [ + body("currentPassword") + .notEmpty() + .withMessage("Current password is required"), + + body("newPassword") + .isLength({ min: 8, max: 128 }) + .withMessage("New password must be between 8 and 128 characters") + .matches(passwordStrengthRegex) + .withMessage( + "New password must contain at least one uppercase letter, one lowercase letter, one number, and one special character" + ) + .custom((value, { req }) => { + if (value === req.body.currentPassword) { + throw new Error("New password must be different from current password"); + } + if (commonPasswords.includes(value.toLowerCase())) { + throw new Error( + "Password is too common. Please choose a stronger password" + ); + } + return true; + }), + + body("confirmPassword").custom((value, { req }) => { + if (value !== req.body.newPassword) { + throw new Error("Password confirmation does not match"); + } + return true; + }), + + handleValidationErrors, +]; + +module.exports = { + sanitizeInput, + handleValidationErrors, + validateRegistration, + validateLogin, + validateGoogleAuth, + validateProfileUpdate, + validatePasswordChange, +}; diff --git a/backend/models/User.js b/backend/models/User.js index b7754f6..cbdd997 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -104,17 +104,25 @@ const User = sequelize.define( type: DataTypes.STRING, allowNull: true, }, + loginAttempts: { + type: DataTypes.INTEGER, + defaultValue: 0, + }, + lockUntil: { + type: DataTypes.DATE, + allowNull: true, + }, }, { hooks: { beforeCreate: async (user) => { if (user.password) { - user.password = await bcrypt.hash(user.password, 10); + user.password = await bcrypt.hash(user.password, 12); } }, beforeUpdate: async (user) => { if (user.changed("password") && user.password) { - user.password = await bcrypt.hash(user.password, 10); + user.password = await bcrypt.hash(user.password, 12); } }, }, @@ -128,4 +136,41 @@ User.prototype.comparePassword = async function (password) { return bcrypt.compare(password, this.password); }; +// Account lockout constants +const MAX_LOGIN_ATTEMPTS = 5; +const LOCK_TIME = 2 * 60 * 60 * 1000; // 2 hours + +// Check if account is locked +User.prototype.isLocked = function() { + return !!(this.lockUntil && this.lockUntil > Date.now()); +}; + +// Increment login attempts and lock account if necessary +User.prototype.incLoginAttempts = async function() { + // If we have a previous lock that has expired, restart at 1 + if (this.lockUntil && this.lockUntil < Date.now()) { + return this.update({ + loginAttempts: 1, + lockUntil: null + }); + } + + const updates = { loginAttempts: this.loginAttempts + 1 }; + + // Lock account after max attempts + if (this.loginAttempts + 1 >= MAX_LOGIN_ATTEMPTS && !this.isLocked()) { + updates.lockUntil = Date.now() + LOCK_TIME; + } + + return this.update(updates); +}; + +// Reset login attempts after successful login +User.prototype.resetLoginAttempts = async function() { + return this.update({ + loginAttempts: 0, + lockUntil: null + }); +}; + module.exports = User; diff --git a/backend/package-lock.json b/backend/package-lock.json index a3784e4..0bab6f4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,11 +12,17 @@ "@googlemaps/google-maps-services-js": "^3.4.2", "bcryptjs": "^3.0.2", "body-parser": "^2.2.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "csrf": "^3.1.0", + "dompurify": "^3.2.6", "dotenv": "^17.2.0", "express": "^5.1.0", "express-rate-limit": "^8.1.0", + "express-validator": "^7.2.1", "google-auth-library": "^10.3.0", + "helmet": "^8.1.0", + "jsdom": "^27.0.0", "jsonwebtoken": "^9.0.2", "multer": "^2.0.2", "node-cron": "^3.0.3", @@ -30,6 +36,178 @@ "nodemon": "^3.1.10" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.4.tgz", + "integrity": "sha512-cKjSKvWGmAziQWbCouOsFwb14mp1betm8Y7Fn+yglDMUUu3r9DCbJ9iJbeFDenLMqFbIMC0pQP8K+B8LAxX3OQ==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.0.10", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.1.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.1.tgz", + "integrity": "sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.5.4.tgz", + "integrity": "sha512-RNSNk1dnB8lAn+xdjlRoM4CzdVrHlmXZtSXAWs2jyl4PiBRWqTZr9ML5M710qgd9RPTBsVG6P0SLy7dwy0Foig==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", + "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@googlemaps/google-maps-services-js": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/@googlemaps/google-maps-services-js/-/google-maps-services-js-3.4.2.tgz", @@ -103,6 +281,13 @@ "undici-types": "~7.8.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/validator": { "version": "13.15.2", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.2.tgz", @@ -205,9 +390,9 @@ } }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -248,6 +433,15 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -564,6 +758,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -603,6 +816,47 @@ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", "license": "MIT" }, + "node_modules/csrf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", + "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", + "license": "MIT", + "dependencies": { + "rndm": "1.2.0", + "tsscmp": "1.0.6", + "uid-safe": "2.1.5" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.0.tgz", + "integrity": "sha512-RveJPnk3m7aarYQ2bJ6iw+Urh55S6FzUiqtBq+TihnTDP4cI8y/TYDqGOyqgnG1J1a6BxJXZsV9JFSTulm9Z7g==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.0.3", + "@csstools/css-syntax-patches-for-csstree": "^1.0.14", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -612,6 +866,19 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -628,6 +895,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/decode-uri-component": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", @@ -654,6 +927,15 @@ "node": ">= 0.8" } }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "17.2.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.0.tgz", @@ -731,6 +1013,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -853,6 +1147,28 @@ "express": ">= 4.11" } }, + "node_modules/express-validator": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", + "integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.12.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/express-validator/node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -1315,6 +1631,27 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -1338,6 +1675,19 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -1476,6 +1826,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -1528,6 +1884,45 @@ "node": ">=14" } }, + "node_modules/jsdom": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz", + "integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^6.5.4", + "cssstyle": "^5.3.0", + "data-urls": "^6.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^7.3.0", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0", + "ws": "^8.18.2", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -1641,6 +2036,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -1807,6 +2208,25 @@ "node": ">= 0.6" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -1990,6 +2410,18 @@ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2132,6 +2564,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -2196,6 +2657,15 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -2228,6 +2698,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2284,6 +2763,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -2320,6 +2808,12 @@ "axios": "*" } }, + "node_modules/rndm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", + "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2335,6 +2829,12 @@ "node": ">= 18" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2359,6 +2859,18 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -2619,6 +3131,15 @@ "node": ">=10" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split-on-first": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", @@ -2801,6 +3322,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, + "node_modules/tldts": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.14.tgz", + "integrity": "sha512-lMNHE4aSI3LlkMUMicTmAG3tkkitjOQGDTFboPJwAg2kJXKP1ryWEyqujktg5qhrFZOkk5YFzgkxg3jErE+i5w==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.14" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.14.tgz", + "integrity": "sha512-viZGNK6+NdluOJWwTO9olaugx0bkKhscIdriQQ+lNNhwitIKvb+SvhbYgnCz6j9p7dX3cJntt4agQAKMXLjJ5g==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2835,6 +3380,39 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -2854,6 +3432,18 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "license": "MIT" }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/umzug": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", @@ -2927,6 +3517,18 @@ "node": ">= 0.8" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -2936,6 +3538,49 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.0.0.tgz", + "integrity": "sha512-+0q+Pc6oUhtbbeUfuZd4heMNOLDJDdagYxv756mCf9vnLF+NTj4zvv5UyYNkHJpc3CJIesMVoEIOdhi7L9RObA==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.1", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3047,6 +3692,42 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index ca67e5b..5b4ac4f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,11 +19,17 @@ "@googlemaps/google-maps-services-js": "^3.4.2", "bcryptjs": "^3.0.2", "body-parser": "^2.2.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "csrf": "^3.1.0", + "dompurify": "^3.2.6", "dotenv": "^17.2.0", "express": "^5.1.0", "express-rate-limit": "^8.1.0", + "express-validator": "^7.2.1", "google-auth-library": "^10.3.0", + "helmet": "^8.1.0", + "jsdom": "^27.0.0", "jsonwebtoken": "^9.0.2", "multer": "^2.0.2", "node-cron": "^3.0.3", diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 31bc164..f438d1a 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -2,11 +2,17 @@ const express = require("express"); const jwt = require("jsonwebtoken"); const { OAuth2Client } = require("google-auth-library"); const { User } = require("../models"); // Import from models/index.js to get models with associations +const { + sanitizeInput, + validateRegistration, + validateLogin, + validateGoogleAuth +} = require("../middleware/validation"); const router = express.Router(); const googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID); -router.post("/register", async (req, res) => { +router.post("/register", sanitizeInput, validateRegistration, async (req, res) => { try { const { username, email, password, firstName, lastName, phone } = req.body; @@ -17,7 +23,10 @@ router.post("/register", async (req, res) => { }); if (existingUser) { - return res.status(400).json({ error: "User already exists" }); + return res.status(400).json({ + error: "Registration failed", + details: [{ field: "email", message: "An account with this email already exists" }] + }); } const user = await User.create({ @@ -44,20 +53,40 @@ router.post("/register", async (req, res) => { token, }); } catch (error) { - res.status(500).json({ error: error.message }); + console.error('Registration error:', error); + res.status(500).json({ error: "Registration failed. Please try again." }); } }); -router.post("/login", async (req, res) => { +router.post("/login", sanitizeInput, validateLogin, async (req, res) => { try { const { email, password } = req.body; const user = await User.findOne({ where: { email } }); - if (!user || !(await user.comparePassword(password))) { + if (!user) { return res.status(401).json({ error: "Invalid credentials" }); } + // Check if account is locked + if (user.isLocked()) { + return res.status(423).json({ + error: "Account is temporarily locked due to too many failed login attempts. Please try again later." + }); + } + + // Verify password + const isPasswordValid = await user.comparePassword(password); + + if (!isPasswordValid) { + // Increment login attempts + await user.incLoginAttempts(); + return res.status(401).json({ error: "Invalid credentials" }); + } + + // Reset login attempts on successful login + await user.resetLoginAttempts(); + const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { expiresIn: "7d", }); @@ -73,11 +102,12 @@ router.post("/login", async (req, res) => { token, }); } catch (error) { - res.status(500).json({ error: error.message }); + console.error('Login error:', error); + res.status(500).json({ error: "Login failed. Please try again." }); } }); -router.post("/google", async (req, res) => { +router.post("/google", sanitizeInput, validateGoogleAuth, async (req, res) => { try { const { idToken } = req.body; @@ -165,9 +195,8 @@ router.post("/google", async (req, res) => { .status(400) .json({ error: "Malformed Google token. Please try again." }); } - res - .status(500) - .json({ error: "Failed to authenticate with Google: " + error.message }); + console.error('Google auth error:', error); + res.status(500).json({ error: "Google authentication failed. Please try again." }); } }); diff --git a/backend/server.js b/backend/server.js index 452f709..7850d5c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -9,7 +9,9 @@ const express = require("express"); const cors = require("cors"); const bodyParser = require("body-parser"); const path = require("path"); +const helmet = require("helmet"); const { sequelize } = require("./models"); // Import from models/index.js to ensure associations are loaded +const { cookieParser } = require("./middleware/csrf"); const authRoutes = require("./routes/auth"); const userRoutes = require("./routes/users"); @@ -25,9 +27,52 @@ const PayoutProcessor = require("./jobs/payoutProcessor"); const app = express(); -app.use(cors()); -app.use(bodyParser.json({ limit: "5mb" })); -app.use(bodyParser.urlencoded({ extended: true, limit: "5mb" })); +// Security headers +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "https://cdn.jsdelivr.net"], + fontSrc: ["'self'"], + scriptSrc: ["'self'", "https://accounts.google.com"], + imgSrc: ["'self'"], + connectSrc: ["'self'"], + frameSrc: ["'self'"], + }, + }, + }) +); + +// Cookie parser for CSRF +app.use(cookieParser); + +// CORS with security settings +app.use( + cors({ + origin: process.env.FRONTEND_URL || "http://localhost:3000", + credentials: true, + optionsSuccessStatus: 200, + }) +); + +// Body parsing with size limits +app.use( + bodyParser.json({ + limit: "1mb", + verify: (req, res, buf) => { + // Store raw body for webhook verification + req.rawBody = buf; + }, + }) +); +app.use( + bodyParser.urlencoded({ + extended: true, + limit: "1mb", + parameterLimit: 100, // Limit number of parameters + }) +); // Serve static files from uploads directory app.use("/uploads", express.static(path.join(__dirname, "uploads"))); @@ -54,10 +99,10 @@ sequelize .sync({ alter: true }) .then(() => { console.log("Database synced"); - + // Start the payout processor const payoutJobs = PayoutProcessor.startScheduledPayouts(); - + app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); }); diff --git a/frontend/src/components/AuthModal.tsx b/frontend/src/components/AuthModal.tsx index e7fc97d..d6890ae 100644 --- a/frontend/src/components/AuthModal.tsx +++ b/frontend/src/components/AuthModal.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from "react"; import { useAuth } from "../contexts/AuthContext"; +import PasswordStrengthMeter from "./PasswordStrengthMeter"; interface AuthModalProps { show: boolean; @@ -198,6 +199,9 @@ const AuthModal: React.FC = ({ onChange={(e) => setPassword(e.target.value)} required /> + {mode === "signup" && ( + + )}