fix(security): audit fixes #4 #6 #10 + hero rewrite (batch 0.5.2)

- #4: Replace su-exec with USER nodejs in Dockerfile (P0)
- #6: Add UNIQUE constraint on leads.email with migration (P1)
- #10: Consistent NULL handling for optional fields (P1)
- Hero section rewrite: B2B value proposition, prominent 8x8 badge
- Clean up .bak file left by agent
This commit is contained in:
null 2026-05-17 14:44:34 -05:00
parent 851759ae5e
commit 7d476f36e8
5 changed files with 148 additions and 46 deletions

View File

@ -40,9 +40,8 @@ ENV ZOHO_REDIRECT_URI=
# Create app directory structure # Create app directory structure
RUN mkdir -p /app/db /app/logs RUN mkdir -p /app/db /app/logs
# Copy entrypoint script # Set permissions for db directory (before USER switch)
COPY docker-entrypoint.sh /usr/local/bin/ RUN chown -R nodejs:nodejs /app/db /app/logs
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Copy from builder - built artifacts and package manifests # Copy from builder - built artifacts and package manifests
COPY --from=builder /app/package.json /app/package-lock.json* ./ COPY --from=builder /app/package.json /app/package-lock.json* ./
@ -52,17 +51,15 @@ COPY --from=builder /app/server ./server
# Install production dependencies only in runtime stage # Install production dependencies only in runtime stage
RUN npm ci --omit=dev RUN npm ci --omit=dev
# Install su-exec for switching to non-root user
RUN apk add --no-cache su-exec && \
rm -rf /var/cache/apk/*
# Expose backend port # Expose backend port
EXPOSE 3001 EXPOSE 3001
# Switch to non-root user (standard approach, no su-exec needed)
USER nodejs
# Health check using Node 20 built-in fetch (no wget required) # Health check using Node 20 built-in fetch (no wget required)
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://localhost:3001/api/health').then(r => r.ok ? 0 : 1).catch(() => 1)" || exit 1 CMD node -e "fetch('http://localhost:3001/api/health').then(r => r.ok ? 0 : 1).catch(() => 1)" || exit 1
# Run the Express server via entrypoint (runs as root, then switches to nodejs) # Run the Express server
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server/index.js"] CMD ["node", "server/index.js"]

View File

@ -5,6 +5,10 @@
set -e set -e
log_error() {
echo "[$(date -Iseconds)] ERROR $1" >&2
}
# Create directories if they don't exist # Create directories if they don't exist
mkdir -p /app/db mkdir -p /app/db
mkdir -p /app/logs mkdir -p /app/logs
@ -13,5 +17,12 @@ mkdir -p /app/logs
chmod 777 /app/db chmod 777 /app/db
chmod 777 /app/logs chmod 777 /app/logs
# Issue #4: Check if nodejs user exists - if not, this is a Docker build error
if ! id nodejs >/dev/null 2>&1; then
log_error "nodejs user does not exist - this is a Docker build error"
exit 1
fi
# Run the Express server as nodejs user # Run the Express server as nodejs user
# Issue #4: Exit with error code 1 if su-exec fails instead of falling back to root
exec su-exec nodejs node server/index.js exec su-exec nodejs node server/index.js

View File

@ -3,6 +3,17 @@ import path from 'path'
import { existsSync, mkdirSync, chmodSync } from 'fs' import { existsSync, mkdirSync, chmodSync } from 'fs'
import sqlite3 from 'better-sqlite3' import sqlite3 from 'better-sqlite3'
// --- Logger ---
const LOG_LEVELS = { error: 0, warn: 1, info: 2, debug: 3 }
const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL?.toLowerCase()] ?? LOG_LEVELS.info
const log = {
info: (...args) => { if (currentLevel >= LOG_LEVELS.info) console.log(`[${new Date().toISOString()}] INFO `, ...args) },
warn: (...args) => { if (currentLevel >= LOG_LEVELS.warn) console.warn(`[${new Date().toISOString()}] WARN `, ...args) },
error: (...args) => { if (currentLevel >= LOG_LEVELS.error) console.error(`[${new Date().toISOString()}] ERROR`, ...args) },
debug: (...args) => { if (currentLevel >= LOG_LEVELS.debug) console.debug(`[${new Date().toISOString()}] DEBUG`, ...args) },
}
const dbPath = path.join(new URL(import.meta.url).pathname, '..', 'db', 'queuenorth.db') const dbPath = path.join(new URL(import.meta.url).pathname, '..', 'db', 'queuenorth.db')
const dbDir = path.dirname(dbPath) const dbDir = path.dirname(dbPath)
@ -22,34 +33,86 @@ export const db = sqlite3(dbPath)
// Initialize schema // Initialize schema
export const initSchema = () => { export const initSchema = () => {
// Leads table // Issue #6: Add UNIQUE constraint on leads.email with migration support
db.exec(` const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='leads'").get()
CREATE TABLE IF NOT EXISTS leads (
id INTEGER PRIMARY KEY AUTOINCREMENT, if (tableExists) {
company TEXT NOT NULL, // Check if UNIQUE constraint already exists
name TEXT NOT NULL, const pragma = db.prepare("PRAGMA table_info(leads)").all()
email TEXT NOT NULL, const emailCol = pragma.find(col => col.name === 'email')
phone TEXT,
zip TEXT, if (!emailCol || !emailCol.notnull) {
message TEXT, // UNIQUE constraint doesn't exist, need to add it via migration
service_interest TEXT, log.info('[DB] Adding UNIQUE constraint on leads.email via migration')
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
// Migrate leads table to add UNIQUE constraint
db.exec(
[
'CREATE TABLE IF NOT EXISTS leads_new (',
' id INTEGER PRIMARY KEY AUTOINCREMENT,',
' company TEXT NOT NULL,',
' name TEXT NOT NULL,',
' email TEXT NOT NULL UNIQUE,',
' phone TEXT,',
' zip TEXT,',
' message TEXT,',
' service_interest TEXT,',
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP',
')'
].join('\n')
) )
`)
// Copy existing data (deduplicate - keep first occurrence per email)
db.exec(
[
'INSERT OR IGNORE INTO leads_new (id, company, name, email, phone, zip, message, service_interest, created_at)',
'SELECT id, company, name, email, phone, zip, message, service_interest, created_at',
'FROM leads'
].join('\n')
)
// Drop old table
db.exec('DROP TABLE leads')
// Rename new table
db.exec('ALTER TABLE leads_new RENAME TO leads')
log.info('[DB] UNIQUE constraint added on leads.email')
}
} else {
// New table - create with UNIQUE constraint from the start
db.exec(
[
'CREATE TABLE IF NOT EXISTS leads (',
' id INTEGER PRIMARY KEY AUTOINCREMENT,',
' company TEXT NOT NULL,',
' name TEXT NOT NULL,',
' email TEXT NOT NULL UNIQUE,',
' phone TEXT,',
' zip TEXT,',
' message TEXT,',
' service_interest TEXT,',
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP',
')'
].join('\n')
)
}
// Support requests table // Support requests table
db.exec(` db.exec(
CREATE TABLE IF NOT EXISTS support_requests ( [
id INTEGER PRIMARY KEY AUTOINCREMENT, 'CREATE TABLE IF NOT EXISTS support_requests (',
name TEXT NOT NULL, ' id INTEGER PRIMARY KEY AUTOINCREMENT,',
company TEXT NOT NULL, ' name TEXT NOT NULL,',
email TEXT NOT NULL, ' company TEXT NOT NULL,',
phone TEXT, ' email TEXT NOT NULL,',
issue TEXT NOT NULL, ' phone TEXT,',
priority TEXT DEFAULT 'medium', ' issue TEXT NOT NULL,',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP ' priority TEXT DEFAULT \'medium\',',
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP',
')'
].join('\n')
) )
`)
} }
initSchema() initSchema()

View File

@ -167,7 +167,9 @@ const sanitizeString = (input, maxLength) => {
sanitized = sanitized.replace(/<script[^>]*>.*?<\/script>/gi, '') sanitized = sanitized.replace(/<script[^>]*>.*?<\/script>/gi, '')
sanitized = sanitized.replace(/<[^>]*>/g, '') sanitized = sanitized.replace(/<[^>]*>/g, '')
// Truncate to max length // Truncate to max length
return sanitized.substring(0, maxLength) sanitized = sanitized.substring(0, maxLength)
// Convert empty strings to undefined so they become NULL in DB
return sanitized === '' ? undefined : sanitized
} }
const sanitizePayload = (data, fields) => { const sanitizePayload = (data, fields) => {
@ -359,6 +361,24 @@ app.post('/api/leads', (req, res) => {
res.json({ success: true, message: "Thanks! We'll be in touch shortly." }) res.json({ success: true, message: "Thanks! We'll be in touch shortly." })
} catch (err) { } catch (err) {
// Issue #6: Handle duplicate email error with 409 Conflict
const errorMsg = err.message?.toLowerCase() || ''
if (errorMsg.includes('unique constraint') || errorMsg.includes('duplicate')) {
log.warn(`Duplicate lead email: ${sanitized.email}`)
// Still forward to Zoho (non-blocking) for existing leads
try {
forwardToZoho(sanitized)
} catch (zohoErr) {
log.warn(`[Zoho] Skipped forwarding for duplicate lead: ${sanitized.email}`)
}
return res.status(409).json({
error: 'Duplicate lead',
message: 'A lead with this email already exists'
})
}
log.error('Error submitting lead:', err) log.error('Error submitting lead:', err)
res.status(500).json({ error: 'Failed to submit lead' }) res.status(500).json({ error: 'Failed to submit lead' })
} }

View File

@ -11,29 +11,40 @@ const Home = () => {
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Hero Section */} {/* Hero Section */}
<section className="bg-primary-navy text-white py-16 md:py-24"> <section className="bg-gradient-to-br from-primary-navy via-primary-navy to-teal-900 text-white py-16 md:py-24">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<div> <div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6"> <h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6">
Modern Communications Infrastructure Without the Vendor Noise Reliable Business Communications Without the Runaround
</h1> </h1>
<p className="text-xl md:text-2xl text-section-alt mb-8 max-w-2xl"> <p className="text-xl md:text-2xl text-section-alt mb-8 max-w-2xl">
We deliver UCaaS, Contact Center, deployment, and managed lifecycle support for SMB and enterprise organizations. We handle your phones, internet, and IT so you can focus on running your business. 8x8 Certified Partner with 25+ years of proven reliability.
</p> </p>
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<Button variant="default" size="lg" className="bg-white text-primary-navy hover:bg-gray-100"> <Button variant="default" size="lg" className="bg-white text-primary-navy hover:bg-gray-100" onClick={() => navigate('/contact')}>
Request Consultation Schedule Consultation
</Button> </Button>
<Button variant="outline" size="lg" className="border-white text-white hover:bg-white/10"> <Button variant="outline" size="lg" className="border-white text-white hover:bg-white/10" onClick={() => navigate('/services')}>
Explore Services View Services
</Button> </Button>
</div> </div>
<div className="mt-10 flex flex-wrap gap-4"> <div className="mt-10 flex flex-wrap gap-4">
<span className="px-4 py-2 bg-white/10 rounded-full text-sm font-medium">8x8 Certified Partner</span> <div className="flex items-center gap-2 px-4 py-2 bg-white/20 rounded-lg text-sm font-medium">
<span className="px-4 py-2 bg-white/10 rounded-full text-sm font-medium">Veteran Owned</span> <img src="/assets/8x8_Logo_White.svg" alt="8x8" className="h-5 w-5" />
<span className="px-4 py-2 bg-white/10 rounded-full text-sm font-numeric">25+ Years Experience</span> <span>8x8 Certified Partner</span>
<span className="px-4 py-2 bg-white/10 rounded-full text-sm font-medium">SMB to Enterprise</span> </div>
<div className="flex items-center gap-2 px-4 py-2 bg-white/20 rounded-lg text-sm font-medium">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center text-primary-navy font-bold">V</div>
<span>Veteran Owned</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-white/20 rounded-lg text-sm font-numeric">
<span>25+</span>
<span className="text-sm">Years Experience</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-white/20 rounded-lg text-sm font-medium">
<span>SMB to Enterprise</span>
</div>
</div> </div>
</div> </div>
<div className="hidden lg:block"> <div className="hidden lg:block">