From 4ac0fa250d56f897ba88c89ba57ffab54080a1a5 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 13 May 2026 18:18:07 -0500 Subject: [PATCH] feat: server-side validation + input sanitization (v0.4.5) --- README.md | 2 +- package.json | 2 +- server/index.js | 117 +++++++++++++++++++++++++++++++++++------------- 3 files changed, 89 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index fa62249..5225642 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Version numbers correlate directly to the active phase: - ~~Contact and support forms fully wired to Express~~ ✅ - ~~SQLite persistence verified~~ ✅ - ~~Client-side validation + Sonner feedback~~ ✅ - - Server-side validation + input sanitization + - ~~Server-side validation + input sanitization~~ ✅ - Optional Zoho forwarding layer - Backend/API hardening as needed diff --git a/package.json b/package.json index 767a4df..e6b4564 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "queuenorth-website", "private": true, - "version": "0.4.4", + "version": "0.4.5", "type": "module", "scripts": { "dev": "concurrently \"vite\" \"node server/index.js\"", diff --git a/server/index.js b/server/index.js index 1de6d4e..729382d 100644 --- a/server/index.js +++ b/server/index.js @@ -20,8 +20,8 @@ if (!existsSync(dbDir)) { } // Middleware -app.use(express.json()) -app.use(express.urlencoded({ extended: true })) +app.use(express.json({ limit: '1mb' })) +app.use(express.urlencoded({ extended: true, limit: '1mb' })) app.use(express.static(path.join(__dirname, '../dist'))) // --- Database --- @@ -61,24 +61,48 @@ const initSchema = () => { initSchema() +// --- Sanitization Helper --- +const sanitizeString = (input, maxLength) => { + if (typeof input !== 'string') return input + // Trim whitespace + let sanitized = input.trim() + // Remove HTML/script tags to prevent XSS + sanitized = sanitized.replace(/]*>.*?<\/script>/gi, '') + sanitized = sanitized.replace(/<[^>]*>/g, '') + // Truncate to max length + return sanitized.substring(0, maxLength) +} + +const sanitizePayload = (data, fields) => { + const result = { ...data } + for (const [field, maxLength] of Object.entries(fields)) { + if (result[field] !== undefined) { + result[field] = sanitizeString(result[field], maxLength) + } + } + return result +} + // --- Validation Schemas --- const leadSchema = z.object({ - company: z.string().min(1, 'Company name is required'), - name: z.string().min(1, 'Name is required'), - email: z.string().email('Valid email is required'), - phone: z.string().optional(), - zip: z.string().optional(), - message: z.string().optional(), - service_interest: z.string().optional(), + company: z.string().min(1, 'Company name is required').trim().max(200, 'Company name must be 200 characters or less'), + name: z.string().min(1, 'Name is required').trim().max(100, 'Name must be 100 characters or less'), + email: z.string().email('Valid email is required').trim().max(254, 'Email must be 254 characters or less'), + phone: z.string().trim().max(50, 'Phone must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)), + zip: z.string().trim().max(10, 'ZIP code must be 10 characters or less').optional().or(z.literal('').transform(() => undefined)), + message: z.string().trim().max(5000, 'Message must be 5000 characters or less').optional().or(z.literal('').transform(() => undefined)), + service_interest: z.string().trim().max(50, 'Service interest must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)), }) const supportSchema = z.object({ - name: z.string().min(1, 'Name is required'), - company: z.string().min(1, 'Company name is required'), - email: z.string().email('Valid email is required'), - phone: z.string().optional(), - issue: z.string().min(10, 'Please provide more details about your issue'), - priority: z.enum(['low', 'medium', 'high']).optional(), + name: z.string().min(1, 'Name is required').trim().max(100, 'Name must be 100 characters or less'), + company: z.string().min(1, 'Company name is required').trim().max(200, 'Company name must be 200 characters or less'), + email: z.string().email('Valid email is required').trim().max(254, 'Email must be 254 characters or less'), + phone: z.string().trim().max(50, 'Phone must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)), + issue: z.string().min(10, 'Please provide at least 10 characters describing your issue').trim().max(5000, 'Issue description must be 5000 characters or less'), + priority: z.enum(['low', 'medium', 'high'], { + errorMap: () => ({ message: 'Priority must be low, medium, or high' }), + }).transform((val) => val?.toLowerCase() ?? undefined).optional().or(z.literal('').transform(() => undefined)), }) // --- API Routes --- @@ -94,25 +118,42 @@ app.post('/api/leads', (req, res) => { const parsed = leadSchema.safeParse(req.body) if (!parsed.success) { + const fieldErrors = {} + for (const issue of parsed.error.issues) { + if (issue.path[0]) { + fieldErrors[issue.path[0]] = issue.message + } + } return res.status(400).json({ error: 'Validation failed', - details: parsed.error.format(), + fields: fieldErrors, }) } + // Sanitize parsed data before insert (trim, strip tags, truncate) + const sanitized = sanitizePayload(parsed.data, { + company: 200, + name: 100, + email: 254, + phone: 50, + zip: 10, + message: 5000, + service_interest: 50, + }) + const stmt = db.prepare(` INSERT INTO leads (company, name, email, phone, zip, message, service_interest) VALUES (?, ?, ?, ?, ?, ?, ?) `) stmt.run( - parsed.data.company, - parsed.data.name, - parsed.data.email, - parsed.data.phone || null, - parsed.data.zip || null, - parsed.data.message || null, - parsed.data.service_interest || null + sanitized.company, + sanitized.name, + sanitized.email, + sanitized.phone || null, + sanitized.zip || null, + sanitized.message || null, + sanitized.service_interest || null ) res.json({ success: true, message: 'Thanks! We\'ll be in touch shortly.' }) @@ -128,24 +169,40 @@ app.post('/api/support', (req, res) => { const parsed = supportSchema.safeParse(req.body) if (!parsed.success) { + const fieldErrors = {} + for (const issue of parsed.error.issues) { + if (issue.path[0]) { + fieldErrors[issue.path[0]] = issue.message + } + } return res.status(400).json({ error: 'Validation failed', - details: parsed.error.format(), + fields: fieldErrors, }) } + // Sanitize parsed data before insert (trim, strip tags, truncate) + const sanitized = sanitizePayload(parsed.data, { + name: 100, + company: 200, + email: 254, + phone: 50, + issue: 5000, + priority: 10, + }) + const stmt = db.prepare(` INSERT INTO support_requests (name, company, email, phone, issue, priority) VALUES (?, ?, ?, ?, ?, ?) `) stmt.run( - parsed.data.name, - parsed.data.company, - parsed.data.email, - parsed.data.phone || null, - parsed.data.issue, - parsed.data.priority || 'medium' + sanitized.name, + sanitized.company, + sanitized.email, + sanitized.phone || null, + sanitized.issue, + sanitized.priority || 'medium' ) res.json({ success: true, message: 'Thanks! We\'ll get back to you soon.' })