diff --git a/wardrobe-ai/backend/package-lock.json b/wardrobe-ai/backend/package-lock.json index 6ebb80b..a1f5090 100644 --- a/wardrobe-ai/backend/package-lock.json +++ b/wardrobe-ai/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "express": "^4.18.2", + "joi": "^18.1.2", "sqlite3": "^5.1.6" } }, @@ -19,6 +20,54 @@ "license": "MIT", "optional": true }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -45,6 +94,12 @@ "node": ">=10" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -1153,6 +1208,24 @@ "license": "ISC", "optional": true }, + "node_modules/joi": { + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz", + "integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", diff --git a/wardrobe-ai/backend/package.json b/wardrobe-ai/backend/package.json index cc830e2..f40e44a 100644 --- a/wardrobe-ai/backend/package.json +++ b/wardrobe-ai/backend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "express": "^4.18.2", + "joi": "^18.1.2", "sqlite3": "^5.1.6" } } diff --git a/wardrobe-ai/backend/server.js b/wardrobe-ai/backend/server.js index 4f790d4..df4bd13 100644 --- a/wardrobe-ai/backend/server.js +++ b/wardrobe-ai/backend/server.js @@ -1,6 +1,7 @@ const express = require('express'); const sqlite3 = require('sqlite3').verbose(); const path = require('path'); +const { sanitizeBody, validate } = require('./src/middleware/validation'); // Amazon Affiliate Configuration const AFFILIATE_ID = process.env.AMAZON_ASSOCIATE_ID || 'greybeardhold-20'; @@ -10,6 +11,7 @@ const PORT = process.env.PORT || 3001; // Middleware app.use(express.json()); +app.use(sanitizeBody); app.use(express.static(path.join(__dirname, '../frontend/public'))); app.use((req, res, next) => { const allowedOrigins = ['http://localhost:3000', 'https://wardrobe.scoble.me']; @@ -129,7 +131,7 @@ app.get('/api/products/category/:category', (req, res) => { }); // Create user -app.post('/api/users', (req, res) => { +app.post('/api/users', validate(require('./src/middleware/validation').schemas.user), (req, res) => { const { email } = req.body; db.run('INSERT INTO users (email) VALUES (?)', [email], function(err) { @@ -142,7 +144,7 @@ app.post('/api/users', (req, res) => { }); // Create style profile -app.post('/api/profiles', (req, res) => { +app.post('/api/profiles', validate(require('./src/middleware/validation').schemas.profile), (req, res) => { const { userId, sizes, preferredColors, stylePreferences, lifestyle, hardLines, mustHaves, budget } = req.body; db.run( @@ -185,7 +187,7 @@ app.get('/api/profile/:userId', (req, res) => { }); // Generate curated box -app.post('/api/box', (req, res) => { +app.post('/api/box', validate(require('./src/middleware/validation').schemas.box), (req, res) => { const { userId, preferredColors, stylePreferences, budget } = req.body; // Validate and default stylePreferences to empty array @@ -268,7 +270,7 @@ app.post('/api/box', (req, res) => { }); // Submit feedback -app.post('/api/feedback', (req, res) => { +app.post('/api/feedback', validate(require('./src/middleware/validation').schemas.feedback), (req, res) => { const { userId, productId, wouldKeep, fitRating, styleRating, colorRating, qualityRating } = req.body; db.run( diff --git a/wardrobe-ai/backend/src/middleware/validation.js b/wardrobe-ai/backend/src/middleware/validation.js new file mode 100644 index 0000000..8d772b2 --- /dev/null +++ b/wardrobe-ai/backend/src/middleware/validation.js @@ -0,0 +1,194 @@ +const Joi = require('joi'); + +// --- Schema Definitions --- + +const emailSchema = Joi.string() + .email() + .required() + .messages({ + 'string.email': 'Invalid email format', + 'any.required': 'Email is required', + }); + +const budgetSchema = Joi.number() + .integer() + .valid(100, 250) + .messages({ + 'number.base': 'Budget must be a number', + 'number.integer': 'Budget must be an integer', + 'any.only': 'Budget must be 100 or 250', + }); + +const ratingSchema = Joi.number() + .integer() + .min(1) + .max(5) + .messages({ + 'number.base': 'Rating must be a number', + 'number.integer': 'Rating must be an integer', + 'number.min': 'Rating must be at least 1', + 'number.max': 'Rating must be at most 5', + }); + +const boxIdSchema = Joi.string() + .min(5) + .required() + .messages({ + 'string.base': 'Box ID must be a string', + 'string.min': 'Box ID must be at least 5 characters', + 'any.required': 'Box ID is required', + }); + +const nonEmptyString = Joi.string() + .min(1) + .required() + .messages({ + 'string.base': 'Value must be a string', + 'string.min': 'Value must not be empty', + 'any.required': 'This field is required', + }); + +const userIdSchema = Joi.string() + .min(1) + .required() + .messages({ + 'string.base': 'User ID must be a string', + 'string.min': 'User ID must not be empty', + 'any.required': 'User ID is required', + }); + +const profileIdSchema = Joi.string() + .min(1) + .required() + .messages({ + 'string.base': 'Profile ID must be a string', + 'string.min': 'Profile ID must not be empty', + 'any.required': 'Profile ID is required', + }); + +// --- Request Schemas --- + +const feedbackSchema = Joi.object({ + userId: userIdSchema, + productId: Joi.number().integer().required(), + boxId: boxIdSchema.optional(), + wouldKeep: Joi.boolean().optional(), + fitRating: ratingSchema.optional(), + styleRating: ratingSchema.optional(), + colorRating: ratingSchema.optional(), + qualityRating: ratingSchema.optional(), + comment: Joi.string().max(500).optional(), +}); + +const boxSchema = Joi.object({ + userId: userIdSchema, + profileId: profileIdSchema.optional(), + preferredColors: Joi.array().items(Joi.string()).optional(), + stylePreferences: Joi.alternatives().try( + Joi.array().items(Joi.string()), + Joi.string() + ).optional(), + budget: budgetSchema.optional(), + totalPrice: budgetSchema.optional(), +}); + +const profileSchema = Joi.object({ + userId: userIdSchema, + sizes: Joi.array().items(Joi.string()).optional(), + preferredColors: Joi.array().items(Joi.string()).optional(), + stylePreferences: Joi.alternatives().try( + Joi.array().items(Joi.string()), + Joi.string() + ).optional(), + lifestyle: Joi.string().max(500).optional(), + hardLines: Joi.array().items(Joi.string()).optional(), + mustHaves: Joi.array().items(Joi.string()).optional(), + budget: budgetSchema.optional(), +}); + +const userSchema = Joi.object({ + email: emailSchema, +}); + +// --- Sanitization --- + +/** + * Recursively sanitize an object: + * - HTML-encodes < and > (XSS prevention) + * - Trims whitespace on strings + */ +function sanitizeValue(value) { + if (typeof value === 'string') { + return value + .replace(//g, '>') + .trim(); + } + if (Array.isArray(value)) { + return value.map(sanitizeValue); + } + if (value !== null && typeof value === 'object') { + const sanitized = {}; + for (const [key, val] of Object.entries(value)) { + sanitized[key] = sanitizeValue(val); + } + return sanitized; + } + return value; +} + +// --- Middleware --- + +/** + * Express middleware that sanitizes req.body recursively + */ +function sanitizeBody(req, res, next) { + if (req.body && typeof req.body === 'object') { + req.body = sanitizeValue(req.body); + } + next(); +} + +/** + * Factory: returns middleware that validates req.body against a Joi schema. + * On failure, responds with 400 + descriptive error messages. + */ +function validate(schema) { + return (req, res, next) => { + const { error, value } = schema.validate(req.body, { + abortEarly: false, + stripUnknown: true, + }); + + if (error) { + const messages = error.details.map((d) => d.message); + return res.status(400).json({ error: 'Validation failed', details: messages }); + } + + req.body = value; + next(); + }; +} + +// --- Exports --- + +module.exports = { + // Middleware + sanitizeBody, + validate, + + // Schemas (for direct use / testing) + schemas: { + feedback: feedbackSchema, + box: boxSchema, + profile: profileSchema, + user: userSchema, + email: emailSchema, + budget: budgetSchema, + rating: ratingSchema, + boxId: boxIdSchema, + }, + + // Utilities + sanitizeValue, +};