Security Best Practices
This guide covers security best practices for the Visita Intelligence Platform. As a civic intelligence platform handling sensitive community data, security is paramount to maintaining public trust.Overview
Visita follows a defense-in-depth security strategy:Copy
┌─────────────────────────────────────────┐
│ Infrastructure Security (Vercel, Supabase) │
├─────────────────────────────────────────┤
│ Application Security (Next.js, TypeScript) │
├─────────────────────────────────────────┤
│ Data Security (RLS, Encryption) │
├─────────────────────────────────────────┤
│ Authentication & Authorization │
├─────────────────────────────────────────┤
│ Monitoring & Incident Response │
└─────────────────────────────────────────┘
Authentication Security
Password Policies
lib/validation/password.ts
Copy
import { z } from 'zod';
export const passwordSchema = z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character');
export function checkPasswordStrength(password: string): {
score: number;
feedback: string[];
} {
const feedback: string[] = [];
let score = 0;
if (password.length >= 8) score += 1;
else feedback.push('Use at least 8 characters');
if (/[A-Z]/.test(password)) score += 1;
else feedback.push('Add uppercase letters');
if (/[a-z]/.test(password)) score += 1;
else feedback.push('Add lowercase letters');
if (/[0-9]/.test(password)) score += 1;
else feedback.push('Add numbers');
if (/[^A-Za-z0-9]/.test(password)) score += 1;
else feedback.push('Add special characters');
if (password.length >= 12) score += 1;
// Check for common passwords
const commonPasswords = ['password', '123456', 'qwerty', 'letmein'];
if (commonPasswords.includes(password.toLowerCase())) {
score = 0;
feedback.push('This password is too common');
}
return { score, feedback };
}
Multi-Factor Authentication (MFA)
components/auth/MFAEnrollment.tsx
Copy
'use client';
import { useState } from 'react';
import { enrollMFA, verifyMFA, generateBackupCodes } from '@/app/actions/auth';
export function MFAEnrollment() {
const [step, setStep] = useState<'start' | 'verify' | 'backup'>('start');
const [qrCode, setQrCode] = useState<string>('');
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const handleEnroll = async () => {
try {
const { qr_code } = await enrollMFA();
setQrCode(qr_code);
setStep('verify');
} catch (error) {
console.error('Failed to enroll MFA:', error);
}
};
const handleVerify = async (code: string) => {
try {
await verifyMFA(code);
const { codes } = await generateBackupCodes();
setBackupCodes(codes);
setStep('backup');
} catch (error) {
console.error('Invalid verification code');
}
};
return (
<div className="max-w-md mx-auto">
{step === 'start' && (
<button onClick={handleEnroll} className="btn btn-primary">
Enable Two-Factor Authentication
</button>
)}
{step === 'verify' && (
<div>
<h3>Scan QR Code</h3>
<img src={qrCode} alt="QR Code" />
<input
type="text"
placeholder="Enter 6-digit code"
maxLength={6}
onChange={(e) => e.target.value.length === 6 && handleVerify(e.target.value)}
/>
</div>
)}
{step === 'backup' && (
<div>
<h3>Backup Codes</h3>
<p>Save these codes in a secure place:</p>
<ul>
{backupCodes.map((code, i) => (
<li key={i} className="font-mono">{code}</li>
))}
</ul>
</div>
)}
</div>
);
}
Session Security
lib/supabase/middleware.ts
Copy
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
request.cookies.set(name, value);
});
supabaseResponse = NextResponse.next({
request,
});
cookiesToSet.forEach(({ name, value, options }) => {
supabaseResponse.cookies.set(name, value, {
...options,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
});
},
},
}
);
const { data: { user }, error } = await supabase.auth.getUser();
// Protect sensitive routes
if (request.nextUrl.pathname.startsWith('/platform') && !user) {
const url = new URL('/auth/login', request.url);
url.searchParams.set('return_to', request.nextUrl.pathname);
return NextResponse.redirect(url);
}
// Force password reset if required
if (user && request.nextUrl.pathname !== '/auth/update-password') {
const { data: profile } = await supabase
.from('profiles')
.select('force_password_reset')
.eq('id', user.id)
.single();
if (profile?.force_password_reset) {
return NextResponse.redirect(new URL('/auth/update-password', request.url));
}
}
return supabaseResponse;
}
Authorization & Access Control
Row Level Security (RLS)
supabase/migrations/20250101_rls_policies.sql
Copy
-- Enable RLS on all tables
ALTER TABLE wards ENABLE ROW LEVEL SECURITY;
ALTER TABLE signals ENABLE ROW LEVEL SECURITY;
ALTER TABLE community_projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Ward data: Readable by everyone, writable by authorized users
CREATE POLICY "Anyone can view ward data" ON wards
FOR SELECT USING (true);
CREATE POLICY "Authorized users can update ward data" ON wards
FOR UPDATE USING (
auth.jwt() ->> 'role' = 'councillor'
OR auth.jwt() ->> 'role' = 'admin'
);
-- Signals: Users can only see signals from their wards
CREATE POLICY "Users can view signals from their wards" ON signals
FOR SELECT USING (
ward_code IN (
SELECT ward_code FROM user_wards WHERE user_id = auth.uid()
)
);
CREATE POLICY "Users can create signals for their wards" ON signals
FOR INSERT WITH CHECK (
ward_code IN (
SELECT ward_code FROM user_wards WHERE user_id = auth.uid()
)
AND creator_id = auth.uid()
);
-- Community projects: Ward residents can propose, councillors can approve
CREATE POLICY "Ward residents can view projects" ON community_projects
FOR SELECT USING (
ward_code IN (
SELECT ward_code FROM user_wards WHERE user_id = auth.uid()
)
);
CREATE POLICY "Ward residents can propose projects" ON community_projects
FOR INSERT WITH CHECK (
ward_code IN (
SELECT ward_code FROM user_wards WHERE user_id = auth.uid()
)
AND proposed_by = auth.uid()
AND status = 'proposed'
);
CREATE POLICY "Councillors can approve projects" ON community_projects
FOR UPDATE USING (
auth.jwt() ->> 'role' = 'councillor'
);
-- Profiles: Users can only edit their own profile
CREATE POLICY "Users can view own profile" ON profiles
FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update own profile" ON profiles
FOR UPDATE USING (auth.uid() = id);
Role-Based Access Control
lib/auth/roles.ts
Copy
export type UserRole = 'citizen' | 'councillor' | 'admin' | 'liaison';
export interface UserPermissions {
canViewWard: boolean;
canSubmitSignal: boolean;
canProposeProject: boolean;
canApproveProject: boolean;
canManageUsers: boolean;
canAccessAdminPanel: boolean;
}
export function getPermissions(role: UserRole): UserPermissions {
const permissions: Record<UserRole, UserPermissions> = {
citizen: {
canViewWard: true,
canSubmitSignal: true,
canProposeProject: true,
canApproveProject: false,
canManageUsers: false,
canAccessAdminPanel: false,
},
councillor: {
canViewWard: true,
canSubmitSignal: true,
canProposeProject: true,
canApproveProject: true,
canManageUsers: false,
canAccessAdminPanel: true,
},
liaison: {
canViewWard: true,
canSubmitSignal: true,
canProposeProject: true,
canApproveProject: false,
canManageUsers: false,
canAccessAdminPanel: false,
},
admin: {
canViewWard: true,
canSubmitSignal: true,
canProposeProject: true,
canApproveProject: true,
canManageUsers: true,
canAccessAdminPanel: true,
},
};
return permissions[role];
}
export function requireRole(userRole: UserRole, requiredRole: UserRole): boolean {
const roleHierarchy: UserRole[] = ['citizen', 'liaison', 'councillor', 'admin'];
return roleHierarchy.indexOf(userRole) >= roleHierarchy.indexOf(requiredRole);
}
Server-Side Authorization
app/actions/admin.ts
Copy
'use server';
import { createClient } from '@/lib/supabase/server';
import { getPermissions, requireRole } from '@/lib/auth/roles';
export async function approveProject(projectId: string) {
const supabase = await createClient();
// Get user
const { data: { user }, error: userError } = await supabase.auth.getUser();
if (!user) {
throw new Error('Unauthorized');
}
// Get user's role
const { data: profile } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single();
if (!profile || !requireRole(profile.role, 'councillor')) {
throw new Error('Insufficient permissions');
}
// Check if user is councillor for this ward
const { data: project } = await supabase
.from('community_projects')
.select('ward_code')
.eq('id', projectId)
.single();
const { data: userWard } = await supabase
.from('user_wards')
.select('ward_code')
.eq('user_id', user.id)
.eq('ward_code', project.ward_code)
.single();
if (!userWard) {
throw new Error('Not authorized for this ward');
}
// Approve project
const { error } = await supabase
.from('community_projects')
.update({ status: 'approved', approved_by: user.id, approved_at: new Date() })
.eq('id', projectId);
if (error) throw error;
return { success: true };
}
Input Validation & Sanitization
Server-Side Validation
lib/validation/schemas.ts
Copy
import { z } from 'zod';
import DOMPurify from 'isomorphic-dompurify';
// Ward code validation
export const wardCodeSchema = z
.string()
.regex(/^[A-Z0-9-]+$/, 'Invalid ward code format')
.min(3, 'Ward code too short')
.max(20, 'Ward code too long');
// Signal submission validation
export const signalSchema = z.object({
title: z
.string()
.min(5, 'Title must be at least 5 characters')
.max(200, 'Title must be less than 200 characters')
.transform((val) => DOMPurify.sanitize(val)),
description: z
.string()
.min(20, 'Description must be at least 20 characters')
.max(5000, 'Description must be less than 5000 characters')
.transform((val) => DOMPurify.sanitize(val)),
category: z.enum(['safety', 'infrastructure', 'environment', 'other']),
location: z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
}),
urgency: z.enum(['low', 'medium', 'high']),
wardCode: wardCodeSchema,
});
// File upload validation
export const fileUploadSchema = z.object({
file: z
.instanceof(File)
.refine((file) => file.size <= 5 * 1024 * 1024, 'File size must be less than 5MB')
.refine(
(file) => ['application/pdf', 'image/jpeg', 'image/png'].includes(file.type),
'Only PDF, JPEG, and PNG files are allowed'
),
description: z
.string()
.max(500, 'Description must be less than 500 characters')
.optional()
.transform((val) => val ? DOMPurify.sanitize(val) : val),
});
Rate Limiting
lib/rate-limit.ts
Copy
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// Different rate limits for different actions
export const signalSubmissionLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 submissions per minute
analytics: true,
});
export const contactFormLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(3, '10 m'), // 3 submissions per 10 minutes
analytics: true,
});
export const apiLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, '1 m'), // 100 requests per minute
analytics: true,
});
export async function checkRateLimit(
identifier: string,
limiter: Ratelimit
): Promise<{ success: boolean; remaining: number }> {
const { success, limit, remaining } = await limiter.limit(identifier);
return { success, remaining };
}
app/actions/signals.ts
Copy
'use server';
import { signalSubmissionLimit } from '@/lib/rate-limit';
import { createClient } from '@/lib/supabase/server';
export async function submitSignal(prevState: any, formData: FormData) {
const supabase = await createClient();
// Get user
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, message: 'Authentication required' };
}
// Check rate limit
const { success, remaining } = await checkRateLimit(
user.id,
signalSubmissionLimit
);
if (!success) {
return {
success: false,
message: `Too many submissions. Try again in a moment. (${remaining} remaining)`,
};
}
// Validate and process signal
// ...
}
Content Security Policy (CSP)
next.config.mjs
Copy
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://va.vercel-scripts.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https: blob:;
connect-src 'self' https://*.supabase.co https://*.upstash.io;
frame-src 'self' https://www.google.com;
media-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`.replace(/\s+/g, ' ').trim(),
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(self)',
},
],
},
];
},
};
export default nextConfig;
Data Encryption
At Rest Encryption
supabase/migrations/20250101_encryption.sql
Copy
-- Enable pgcrypto extension
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Create encryption key (store securely in vault)
-- This should be done once and the key stored in a secure vault
-- SELECT gen_random_uuid(); -- Use this to generate a key
-- Function to encrypt sensitive data
CREATE OR REPLACE FUNCTION encrypt_sensitive(value TEXT, key TEXT)
RETURNS BYTEA AS $$
BEGIN
RETURN pgp_sym_encrypt(value, key);
END;
$$ LANGUAGE plpgsql;
-- Function to decrypt sensitive data
CREATE OR REPLACE FUNCTION decrypt_sensitive(value BYTEA, key TEXT)
RETURNS TEXT AS $$
BEGIN
RETURN pgp_sym_decrypt(value, key);
END;
$$ LANGUAGE plpgsql;
-- Example: Encrypt PII in profiles table
ALTER TABLE profiles ADD COLUMN email_encrypted BYTEA;
ALTER TABLE profiles ADD COLUMN phone_encrypted BYTEA;
-- Update trigger to encrypt on insert/update
CREATE OR REPLACE FUNCTION encrypt_profile_pii()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.email IS NOT NULL THEN
NEW.email_encrypted := encrypt_sensitive(NEW.email, current_setting('app.encryption_key'));
NEW.email := NULL; -- Don't store plain text
END IF;
IF NEW.phone IS NOT NULL THEN
NEW.phone_encrypted := encrypt_sensitive(NEW.phone, current_setting('app.encryption_key'));
NEW.phone := NULL; -- Don't store plain text
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_encrypt_profile_pii
BEFORE INSERT OR UPDATE ON profiles
FOR EACH ROW
EXECUTE FUNCTION encrypt_profile_pii();
In Transit Encryption
lib/supabase/client.ts
Copy
import { createClient } from '@supabase/supabase-js';
// Always use HTTPS in production
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient(supabaseUrl, supabaseKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
flowType: 'pkce', // Use PKCE for additional security
},
global: {
fetch: (...args) => {
// Ensure all requests use HTTPS
const [url, options] = args;
if (typeof url === 'string' && !url.startsWith('https://')) {
throw new Error('Insecure request blocked');
}
return fetch(...args);
},
},
});
Audit Logging
Database Audit Trail
supabase/migrations/20250101_audit_log.sql
Copy
-- Create audit log table
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
record_id UUID,
old_values JSONB,
new_values JSONB,
user_id UUID REFERENCES auth.users(id),
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create index for performance
CREATE INDEX idx_audit_log_table ON audit_log(table_name);
CREATE INDEX idx_audit_log_user ON audit_log(user_id);
CREATE INDEX idx_audit_log_created ON audit_log(created_at);
-- Generic audit trigger function
CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO audit_log (
table_name,
operation,
record_id,
new_values,
user_id
) VALUES (
TG_TABLE_NAME,
'INSERT',
NEW.id,
row_to_json(NEW),
current_setting('app.user_id', true)::UUID
);
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO audit_log (
table_name,
operation,
record_id,
old_values,
new_values,
user_id
) VALUES (
TG_TABLE_NAME,
'UPDATE',
NEW.id,
row_to_json(OLD),
row_to_json(NEW),
current_setting('app.user_id', true)::UUID
);
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO audit_log (
table_name,
operation,
record_id,
old_values,
user_id
) VALUES (
TG_TABLE_NAME,
'DELETE',
OLD.id,
row_to_json(OLD),
current_setting('app.user_id', true)::UUID
);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Apply audit trigger to sensitive tables
CREATE TRIGGER audit_signals
AFTER INSERT OR UPDATE OR DELETE ON signals
FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
CREATE TRIGGER audit_projects
AFTER INSERT OR UPDATE OR DELETE ON community_projects
FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
CREATE TRIGGER audit_profiles
AFTER INSERT OR UPDATE OR DELETE ON profiles
FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();
Application-Level Logging
lib/audit.ts
Copy
import { createClient } from '@/lib/supabase/server';
export async function logSecurityEvent(
event: string,
details: Record<string, any>,
userId?: string
) {
const supabase = await createClient();
try {
await supabase.from('security_events').insert({
event_type: event,
details: JSON.stringify(details),
user_id: userId,
ip_address: details.ipAddress,
user_agent: details.userAgent,
severity: details.severity || 'info',
});
} catch (error) {
console.error('Failed to log security event:', error);
// Fallback to console logging
console.log('SECURITY EVENT:', event, details);
}
}
export async function logFailedLogin(email: string, ipAddress: string) {
await logSecurityEvent('login_failed', {
email,
ipAddress,
severity: 'warning',
});
}
export async function logSuccessfulLogin(userId: string, ipAddress: string) {
await logSecurityEvent('login_success', {
userId,
ipAddress,
severity: 'info',
});
}
export async function logSuspiciousActivity(
event: string,
details: Record<string, any>,
userId?: string
) {
await logSecurityEvent(event, {
...details,
severity: 'high',
}, userId);
// Optionally send alert to security team
// await sendSecurityAlert(event, details);
}
Security Headers
Middleware Security
middleware.ts
Copy
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Add security headers
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(self)');
// HSTS in production
if (process.env.NODE_ENV === 'production') {
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
// Rate limiting
const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? 'unknown';
const identifier = `middleware:${ip}`;
// Simple in-memory rate limiting for middleware
// For production, use Redis-based rate limiting
const rateLimitKey = `rate_limit:${identifier}`;
const rateLimitCount = parseInt(await redis.get(rateLimitKey) || '0');
if (rateLimitCount > 100) {
return new NextResponse('Too Many Requests', { status: 429 });
}
await redis.incr(rateLimitKey);
await redis.expire(rateLimitKey, 60); // Reset every minute
return response;
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};
API Security
Webhook Security
app/api/webhooks/paystack/route.ts
Copy
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
export async function POST(request: NextRequest) {
try {
const body = await request.text();
const signature = request.headers.get('x-paystack-signature');
if (!signature) {
return new NextResponse('Unauthorized', { status: 401 });
}
// Verify webhook signature
const expectedSignature = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY!)
.update(body)
.digest('hex');
if (signature !== expectedSignature) {
return new NextResponse('Invalid signature', { status: 401 });
}
const payload = JSON.parse(body);
// Process webhook
// ...
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
return new NextResponse('Internal Server Error', { status: 500 });
}
}
API Key Authentication
lib/api-auth.ts
Copy
import { NextRequest } from 'next/server';
export function verifyApiKey(request: NextRequest): boolean {
const apiKey = request.headers.get('x-api-key');
if (!apiKey) {
return false;
}
// Hash the provided API key
const hashedKey = crypto
.createHash('sha256')
.update(apiKey)
.digest('hex');
// Compare with stored hashes
const validKeys = process.env.API_KEYS?.split(',') || [];
return validKeys.includes(hashedKey);
}
export function requireApiKey(handler: (request: NextRequest) => Promise<Response>) {
return async (request: NextRequest) => {
if (!verifyApiKey(request)) {
return new Response('Unauthorized', { status: 401 });
}
return handler(request);
};
}
Security Testing
Security Headers Test
__tests__/security/headers.test.ts
Copy
import { NextResponse } from 'next/server';
import { middleware } from '@/middleware';
describe('Security Headers', () => {
it('adds security headers to responses', async () => {
const request = new NextRequest('http://localhost:3000');
const response = await middleware(request);
expect(response.headers.get('X-Frame-Options')).toBe('DENY');
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
expect(response.headers.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin');
});
it('adds HSTS header in production', async () => {
process.env.NODE_ENV = 'production';
const request = new NextRequest('http://localhost:3000');
const response = await middleware(request);
expect(response.headers.get('Strict-Transport-Security')).toContain('max-age=31536000');
});
});
Input Validation Tests
__tests__/security/validation.test.ts
Copy
import { signalSchema, fileUploadSchema } from '@/lib/validation/schemas';
describe('Input Validation', () => {
describe('signalSchema', () => {
it('rejects XSS attempts', () => {
const maliciousInput = {
title: '<script>alert("XSS")</script>',
description: 'Test description',
category: 'safety',
location: { lat: -26.2041, lng: 28.0473 },
urgency: 'low',
wardCode: 'WARD-123',
};
const result = signalSchema.safeParse(maliciousInput);
expect(result.success).toBe(true);
// Check that script tags are sanitized
expect(result.data.title).not.toContain('<script>');
});
it('rejects invalid ward codes', () => {
const invalidInput = {
title: 'Test Signal',
description: 'Test description',
category: 'safety',
location: { lat: -26.2041, lng: 28.0473 },
urgency: 'low',
wardCode: 'invalid ward code!',
};
const result = signalSchema.safeParse(invalidInput);
expect(result.success).toBe(false);
});
});
describe('fileUploadSchema', () => {
it('rejects files larger than 5MB', () => {
const largeFile = new File(['x'.repeat(6 * 1024 * 1024)], 'test.pdf', {
type: 'application/pdf',
});
const result = fileUploadSchema.safeParse({ file: largeFile });
expect(result.success).toBe(false);
});
it('rejects disallowed file types', () => {
const exeFile = new File(['test'], 'malware.exe', {
type: 'application/octet-stream',
});
const result = fileUploadSchema.safeParse({ file: exeFile });
expect(result.success).toBe(false);
});
});
});
Incident Response
Security Incident Playbook
lib/incident-response.ts
Copy
export async function handleSecurityIncident(
incident: SecurityIncident
): Promise<void> {
console.error('SECURITY INCIDENT DETECTED:', incident);
// 1. Immediate containment
if (incident.type === 'account_compromise') {
await supabase.auth.admin.updateUserById(incident.userId, {
ban_duration: 'none',
});
}
// 2. Log incident
await logSecurityEvent('security_incident', {
type: incident.type,
severity: 'critical',
details: incident.details,
userId: incident.userId,
ipAddress: incident.ipAddress,
});
// 3. Alert security team
await sendSecurityAlert(incident);
// 4. Preserve evidence
await preserveEvidence(incident);
// 5. Notify affected users if necessary
if (incident.affectsUsers) {
await notifyAffectedUsers(incident);
}
}
export interface SecurityIncident {
type: 'account_compromise' | 'data_breach' | 'suspicious_activity' | 'dos_attack';
severity: 'low' | 'medium' | 'high' | 'critical';
details: Record<string, any>;
userId?: string;
ipAddress?: string;
affectsUsers: boolean;
}
Security Checklist
Pre-Deployment Checklist
- All dependencies updated and scanned for vulnerabilities
- Security headers configured correctly
- CSP policy tested and validated
- Rate limiting enabled for all endpoints
- Input validation in place for all forms
- RLS policies tested and verified
- Audit logging enabled
- Encryption configured for sensitive data
- MFA enforced for admin accounts
- Security tests passing
- Penetration testing completed (quarterly)
Monitoring Checklist
- Failed login attempts monitored
- Suspicious activity alerts enabled
- Rate limit violations tracked
- Audit logs reviewed regularly
- Security events dashboard active
- Incident response plan documented
- Security team contact information current
Related Documentation
- Authentication - Auth system implementation
- Database Schema - RLS and data security
- Deployment - Production security
- Testing - Security testing strategies
Status: Active
Last Updated: December 2025
Security Level: Enterprise Grade