feat: server-side validation + input sanitization (v0.4.5)
This commit is contained in:
parent
ee5af44b58
commit
4ac0fa250d
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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\"",
|
||||
|
|
|
|||
117
server/index.js
117
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[^>]*>.*?<\/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.' })
|
||||
|
|
|
|||
Loading…
Reference in New Issue