Merge remote-tracking branch 'origin/feature/input-validation'
This commit is contained in:
commit
f90275f631
4 changed files with 274 additions and 4 deletions
73
wardrobe-ai/backend/package-lock.json
generated
73
wardrobe-ai/backend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"joi": "^18.1.2",
|
||||
"sqlite3": "^5.1.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -13,6 +14,7 @@ const AFFILIATE_ID = process.env.AMAZON_ASSOCIATE_ID || 'greybeardhold-20';
|
|||
|
||||
// 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'];
|
||||
|
|
@ -132,7 +134,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) {
|
||||
|
|
@ -145,7 +147,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(
|
||||
|
|
@ -188,7 +190,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
|
||||
|
|
@ -271,7 +273,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(
|
||||
|
|
|
|||
194
wardrobe-ai/backend/src/middleware/validation.js
Normal file
194
wardrobe-ai/backend/src/middleware/validation.js
Normal file
|
|
@ -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, '<')
|
||||
.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,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue