Merge remote-tracking branch 'origin/feature/input-validation'

This commit is contained in:
Scott Scoble 2026-05-02 22:49:55 -07:00
commit f90275f631
4 changed files with 274 additions and 4 deletions

View file

@ -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",

View file

@ -10,6 +10,7 @@
},
"dependencies": {
"express": "^4.18.2",
"joi": "^18.1.2",
"sqlite3": "^5.1.6"
}
}

View file

@ -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(

View 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, '&lt;')
.replace(/>/g, '&gt;')
.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,
};