This document outlines security practices, threat mitigation strategies, and compliance guidelines for the Sierra Leone Business Directory application.
- Security Overview
- Data Protection
- Authentication & Authorization
- Input Validation
- API Security
- Database Security
- Deployment Security
- Compliance & Privacy
- Incident Response
- Security Checklist
The application implements the following security best practices:
✅ Input Validation - Zod schema validation on all API endpoints
✅ SQL Injection Prevention - Parameterized queries via Drizzle ORM
✅ XSS Protection - React/Next.js built-in XSS protection
✅ Environment Variables - Sensitive data in .env (never committed)
✅ Type Safety - TypeScript for compile-time type checking
✅ Error Handling - Secure error messages (no stack traces in production)
- Confidentiality - Protect sensitive business data
- Integrity - Ensure data accuracy and prevent tampering
- Availability - Maintain service reliability
- Compliance - Meet regulatory requirements
Potential Threats:
- Unauthorized data access
- SQL injection attacks
- Cross-site scripting (XSS)
- API abuse and rate limiting
- Data breaches
- Man-in-the-middle (MITM) attacks
- Denial of Service (DoS)
- Business names
- Registration numbers
- Industry classification
- Location information
- Public ratings and reviews
- Financial information (revenue, investments)
- Contact information
- Director details
- Compliance scores
- Database credentials
- API keys
- User authentication tokens
- System logs with sensitive info
# For production, ensure database encryption:
# Neon: Encryption included by default
# Supabase: Enable database encryption in settings
# Azure PostgreSQL: Enable "Enforce SSL connection" and encryption at rest
DATABASE_URL should use sslmode=require# Automated backups with encryption
# Example with Neon:
# - Daily automated backups
# - Backups encrypted at rest
# - 7-day retention by default
# For self-hosted PostgreSQL:
# 1. Enable WAL (Write-Ahead Logging)
# 2. Store backups in encrypted storage
# 3. Restrict backup file permissions
# 4. Test restore procedures regularly// next.config.ts - Force HTTPS in production
const nextConfig = {
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "X-XSS-Protection",
value: "1; mode=block",
},
],
},
];
},
};# ❌ NEVER commit these to git
DATABASE_URL
API_KEYS
SECRETS
PASSWORDS
WASENDER_API_KEY
# ✅ Always use .env files (add to .gitignore)
# .gitignore should contain:
.env
.env.local
.env.*.localThe application can optionally integrate with Wasender API to provide WhatsApp-based company verification via the /api/webhook endpoint.
- Treat
WASENDER_API_KEYas a highly sensitive secret:- Store it only in environment variables or your hosting provider's secret store.
- Rotate it periodically and immediately if you suspect compromise.
- Restrict webhook access as much as possible:
- Configure any IP allowlisting features offered by Wasender.
- Prefer HTTPS-only URLs for the webhook (
https://.../api/webhook).
- Logging:
- Do not log full WhatsApp message bodies or phone numbers in production logs unless strictly necessary.
- When debugging, sanitize logs to avoid storing personal data from end users.
- Abuse protection:
- Add rate limiting in front of
/api/webhookin production (at the reverse proxy / platform level) to prevent abuse. - Monitor for unusual spikes in webhook traffic.
- Add rate limiting in front of
- Future hardening (recommended):
- If Wasender supports signed webhooks, add signature verification in
app/api/webhook/route.tsand reject unsigned/invalid requests.
- If Wasender supports signed webhooks, add signature verification in
These measures help ensure that the WhatsApp channel remains secure and that user phone numbers and message contents are handled responsibly.
The application currently has no authentication system. It's designed as a public directory.
When adding authentication, follow these practices:
// Do NOT implement password hashing yourself
// Use established libraries like bcrypt or argon2
import bcrypt from "bcrypt";
// Hash password (development only - use proper auth library)
const hash = await bcrypt.hash(password, 12);
// Verify password
const isValid = await bcrypt.compare(password, hash);// Use established session libraries
// Examples:
// - NextAuth.js (recommended for Next.js)
// - Iron Session
// - Lucia
// Requirements:
// 1. Secure cookies (httpOnly, secure, sameSite)
// 2. CSRF protection tokens
// 3. Session timeout
// 4. Secure session storageProposed role-based access control (RBAC):
enum UserRole {
ADMIN = "admin", // Full access
MODERATOR = "moderator", // Can moderate complaints
BUSINESS_OWNER = "owner", // Can manage own business
ANALYST = "analyst", // Read-only access
USER = "user", // Basic access
}
// Authorization middleware
function requireRole(allowedRoles: UserRole[]) {
return (req: NextRequest, context) => {
const userRole = req.headers.get("x-user-role");
if (!allowedRoles.includes(userRole)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
};
}The application uses Zod for schema validation on all API endpoints.
// lib/validation.ts
import { z } from "zod";
export const businessSearchSchema = z.object({
search: z.string().max(255).optional(),
industry: z.enum([
"technology",
"banking_finance",
"agriculture",
// ...
]).optional(),
minRating: z.number().min(0).max(5).optional(),
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(20),
});
// Usage in API route
const validated = businessSearchSchema.safeParse(queryParams);
if (!validated.success) {
return NextResponse.json(
{ error: "Invalid parameters" },
{ status: 400 }
);
}// ❌ NEVER trust user input
const name = req.body.name; // Unsafe!
// ✅ ALWAYS validate
const schema = z.object({
name: z.string().min(1).max(255),
email: z.string().email(),
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/), // E.164 format
});
const validated = schema.safeParse(req.body);// Sanitize HTML input
import DOMPurify from "isomorphic-dompurify";
const cleanInput = DOMPurify.sanitize(userInput);
// Validate URLs
const urlSchema = z.string().url().max(2048);
// Validate phone numbers
const phoneSchema = z.string().regex(/^\+232\d{9}$/);
// Validate registration numbers
const regNumberSchema = z.string().regex(/^[A-Z]{2}\d{8}$/);// app/api/explore/route.ts
const querySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20), // Max 100
search: z.string().max(255).optional(),
// ... other fields
});
// Prevents parameter pollution and abuse// ❌ VULNERABLE - Never use string concatenation
const query = `SELECT * FROM business WHERE name = '${search}'`;
// ✅ SAFE - Use Drizzle ORM with parameterized queries
const businesses = await db
.select()
.from(business)
.where(ilike(business.name, `%${search}%`))
.execute();// Recommended: Use middleware like express-rate-limit or custom implementation
import rateLimit from "express-rate-limit";
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: "Too many requests from this IP",
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false, // Disable X-RateLimit-* headers
});
// Apply to routes
export const GET = limiter(handler);// Future implementation for cross-origin requests
const corsHeaders = {
"Access-Control-Allow-Origin": process.env.ALLOWED_ORIGINS || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
// Only allow specific origins in production
const allowedOrigins = [
"https://yourdomain.com",
"https://admin.yourdomain.com",
];
function setCorsHeaders(origin: string) {
if (allowedOrigins.includes(origin)) {
return { "Access-Control-Allow-Origin": origin };
}
return {};
}// lib/errorHandler.ts
export function errorResponse(
statusCode: number,
error: Error | string | any,
publicMessage: string,
) {
if (process.env.NODE_ENV === "development") {
// Show detailed errors in development
console.error("API Error:", error);
}
// ❌ NEVER expose internal errors to users
// ✅ Always return generic message in production
return NextResponse.json(
{
ok: false,
message: publicMessage, // User-friendly message
error: publicMessage,
data: null,
},
{ status: statusCode }
);
}Add these headers to all responses:
# next.config.ts headers configuration
headers: [
{
source: "/:path*",
headers: [
# Prevent MIME sniffing
{ key: "X-Content-Type-Options", value: "nosniff" },
# Prevent clickjacking
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
# XSS protection
{ key: "X-XSS-Protection", value: "1; mode=block" },
# HSTS - Force HTTPS
{
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains; preload"
},
# CSP - Content Security Policy
{
key: "Content-Security-Policy",
value: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
},
],
},
]-- Create application user (not superuser)
CREATE ROLE app_user WITH LOGIN PASSWORD 'strong_password';
-- Grant specific permissions
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
-- Revoke public schema access
REVOKE ALL ON SCHEMA public FROM PUBLIC;
-- Disable superuser login
ALTER USER postgres WITH NOLOGIN;# .env production
# Always use SSL/TLS for database connections
DATABASE_URL="postgresql://user:password@host:5432/db?sslmode=require&ssl=true"
# Connection pooling with PgBouncer
DATABASE_URL="postgresql://user:password@pgbouncer:6432/db?sslmode=require"// Enable query logging for security audits
// In Drizzle ORM config:
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./db/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,
},
// Enable query logging in development
verbose: process.env.NODE_ENV === "development",
});-- Row-level security (future implementation)
ALTER TABLE business ENABLE ROW LEVEL SECURITY;
-- Only allow access to non-sensitive fields
CREATE POLICY "public_access" ON business
USING (verification_level = 'verified');
-- Admin can see all
CREATE POLICY "admin_access" ON business
USING (current_user_id = admin_id);# Development
NODE_ENV="development"
DATABASE_URL="postgresql://user:password@localhost:5432/db"
# Production
NODE_ENV="production"
DATABASE_URL="postgresql://user:password@secure-host:5432/db?sslmode=require"DO NOT:
- ❌ Commit
.envfiles - ❌ Log sensitive data
- ❌ Expose stack traces to users
- ❌ Use plaintext passwords
- ❌ Share credentials via email/chat
DO:
- ✅ Use platform secret management (Vercel, Railway, etc.)
- ✅ Rotate secrets regularly
- ✅ Use strong passwords (20+ characters)
- ✅ Enable 2FA on deployment platforms
- ✅ Audit access logs
# Settings → Environment Variables
DATABASE_URL=*** (encrypted)
# Settings → Deployment Protection
# Enable production deployment protection# Variables are encrypted at rest
railway variables set DATABASE_URL "value"
# Enable environment protection# Use multi-stage builds to reduce image size
FROM node:20-alpine AS builder
# Build stage
FROM node:20-alpine
# Production stage - doesn't include build tools# For production deployment, use:
# - Let's Encrypt (free, automatic renewal)
# - Vercel (automatic HTTPS)
# - AWS ACM (free for AWS users)
# Minimum TLS version: 1.2
# Cipher suites: Modern strong ciphers only- Privacy policy on website
- Consent management for data collection
- Right to deletion (forgotten)
- Data portability
- Breach notification within 72 hours
- Privacy policy with specific rights
- Opt-out mechanism
- Sale of data disclosure
- User data access requests
- Comply with Statistics Office requirements
- Respect National Data Protection legislation (when enacted)
- Maintain data securely within agreed jurisdiction
Your privacy policy should address:
# Privacy Policy
## Data We Collect
- Business registration information (public)
- User feedback/complaints (with consent)
- Website usage analytics (optional)
## How We Use Data
- Directory search and filtering
- Complaint investigation
- Service improvement
- Legal compliance
## Data Retention
- Public business data: Until registration expires
- Complaints: 2 years
- Analytics: 1 year (if enabled)
## User Rights
- Access your data
- Correct inaccurate data
- Request deletion (where applicable)
- File complaints
## Contact
[Contact information]Include terms covering:
# Terms of Service
## Use Restrictions
- No automated scraping
- No redistribution of data
- No competitive use
- No illegal activities
## Liability Limitation
- Service provided "as-is"
- No warranties
- No liability for data accuracy
- No liability for service interruptions
## Intellectual Property
- Database structure protected
- UI/UX protected
- Content provided by users// Monitor for suspicious activity
import { logger } from "@/lib/logger";
logger.error("Unusual query pattern detected", {
userId,
endpoint,
method,
timestamp,
});Step 1: Isolate
- Take affected service offline
- Preserve logs and evidence
- Notify security team
Step 2: Investigate
- Analyze access logs
- Identify affected data
- Determine root cause
Step 3: Remediate
- Patch vulnerability
- Reset affected credentials
- Deploy fix
Step 4: Communicate
- Notify affected users (if data breach)
- Update status page
- File incident report
If data breach occurs:
# Within 72 hours:
1. Notify all affected users
2. Notify relevant authorities
3. Provide details:
- What data was breached
- Who was affected
- When it occurred
- What we're doing about it
- What users should do
# Email template:
Subject: Security Notice - Account Data Exposure
Dear Users,
We discovered that [description of breach] on [date].
This may affect your [what data] was exposed.
Actions we took: [remediation steps]
What you should do: [recommended actions]
For questions: security@domain.com# Keep updated contact list:
security_lead:
name: "Name"
email: "security@domain.com"
phone: "+232-xxx-xxxx"
database_admin:
name: "Name"
email: "dba@domain.com"
cloud_provider:
# For urgent security issues
support_link: "https://platform.com/support"- All user input validated with Zod
- No SQL query string concatenation
- TypeScript strict mode enabled
- Environment variables validated on startup
- Error messages don't expose internals
- Sensitive data never logged
- CORS properly configured
- Security headers configured
- Rate limiting implemented (or planned)
- Secrets not committed to git
- Input validation tests written
- SQL injection tests (negative testing)
- XSS prevention verified
- Authentication/authorization tested
- API error handling tested
- Database access control verified
-
.envin.gitignore - Build succeeds without warnings
- All secrets configured on platform
- HTTPS enabled
- Database backups configured
- Monitoring/alerts enabled
- Security headers deployed
- SSL/TLS certificate valid
- Database user has minimal permissions
- Firewall rules configured
- HTTPS working correctly
- Security headers present
- Error handling verified
- Database connected securely
- Logging working properly
- Monitoring dashboards active
- Backup/restore tested
- Incident response plan shared
- Dependencies updated monthly
- Security advisories monitored
- Access logs reviewed weekly
- Backups tested monthly
- Disaster recovery plan updated
- Team security training completed
// Validate input
const schema = z.object({
email: z.string().email(),
age: z.number().min(18).max(120),
});
const result = schema.safeParse(input);
if (!result.success) {
return errorResponse(400, result.error);
}
// Use parameterized queries
const user = await db
.select()
.from(users)
.where(eq(users.email, validatedEmail))
.execute();
// Hash passwords
const hashed = await bcrypt.hash(password, 12);
// Set secure headers
res.setHeader("X-Content-Type-Options", "nosniff");// Concatenate user input into SQL
const query = `SELECT * FROM users WHERE email = '${email}'`;
// Trust user input without validation
const limit = req.query.limit; // Could be 999999999
// Expose sensitive errors
catch (error) {
res.json({ error: error.message }); // Exposes DB structure
}
// Store secrets in code
const dbPassword = "hardcoded_password";
// Log sensitive data
console.log("User password:", password);
// Trust client-side validation only
// Always validate server-side- Dependabot - Automated dependency updates
- Snyk - Vulnerability scanning
- OWASP ZAP - Penetration testing
- Burp Suite - Security testing
- npm audit - Check for known vulnerabilities
- GitHub: Security Issues
Last Updated: December 2025 Version: 1.0 Next Review: January 2026