Security Guide
This guide covers security best practices for developing features in the D'n'A Cruises system.
Input Validation
Always Validate Input
All user inputs must be validated before processing:
import { validateEmail, validatePassword, validateId, sanitizeString } from '../utils/validation';
// Email validation
if (!email || typeof email !== 'string' || !validateEmail(email)) {
return new Response(
JSON.stringify(createErrorResponse('VALIDATION_ERROR', 'Invalid email')),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
email = email.toLowerCase().trim();
// Password validation
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return new Response(
JSON.stringify(createErrorResponse('VALIDATION_ERROR', passwordValidation.error)),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// ID validation
const validId = validateId(id);
if (validId === null) {
return new Response(
JSON.stringify(createErrorResponse('VALIDATION_ERROR', 'Invalid ID')),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// String sanitization
const sanitizedName = sanitizeString(name, 255);
Available Validation Functions
validateEmail(email: string): boolean- Email format validationvalidatePassword(password: string): { valid: boolean; error?: string }- Password strength (min 8 chars, max 128, common passwords check)validateId(id: any): number | null- Positive integer ID validationvalidateStringLength(str: string, min: number, max: number): boolean- String length validationvalidateRole(role: any): role is 'Loader' | 'Producer' | 'Admin'- Role validationvalidateTimestamp(timestamp: any): number | null- Unix timestamp validationvalidatePhoneNumber(phone: string): boolean- Phone number validationsanitizeString(str: string, maxLength: number): string- Sanitize and trim string, remove control characters
SQL Injection Prevention
Always Use Prepared Statements
❌ NEVER do this:
// BAD - SQL injection vulnerability
const query = `SELECT * FROM items WHERE name = '${name}'`;
const result = await env.DB.prepare(query).first();
✅ ALWAYS do this:
// GOOD - Prepared statement
const result = await env.DB.prepare('SELECT * FROM items WHERE name = ?')
.bind(name)
.first();
Dynamic SQL Parts
For dynamic SQL parts (column names, ORDER BY), use whitelisting:
❌ NEVER do this:
// BAD - SQL injection vulnerability
const orderBy = request.query.order_by || 'id';
const query = `SELECT * FROM items ORDER BY ${orderBy}`;
✅ ALWAYS do this:
// GOOD - Whitelisted columns
const allowedColumns = ['id', 'name', 'created_at'];
const orderBy = allowedColumns.includes(request.query.order_by)
? request.query.order_by
: 'id';
const query = `SELECT * FROM items ORDER BY ${orderBy}`;
// Note: orderBy is whitelisted, so this is safe
XSS Prevention
Sanitize All String Inputs
All user-provided strings must be sanitized before storage:
import { sanitizeString } from '../utils/validation';
// Sanitize before database insert/update
const sanitizedName = sanitizeString(name, 255);
const sanitizedNotes = sanitizeString(notes, 2000);
await env.DB.prepare(
'INSERT INTO items (name, notes) VALUES (?, ?)'
)
.bind(sanitizedName, sanitizedNotes)
.run();
Filename Sanitization
For file uploads, use additional sanitization:
let sanitizedFilename = file.name;
// Remove path separators and null bytes
sanitizedFilename = sanitizedFilename.replace(/[\/\\\x00]/g, '');
// Remove leading/trailing dots and spaces
sanitizedFilename = sanitizedFilename.replace(/^[\s.]+|[\s.]+$/g, '');
// Limit length
if (sanitizedFilename.length > 255) {
const ext = sanitizedFilename.split('.').pop();
const nameWithoutExt = sanitizedFilename.substring(0, sanitizedFilename.lastIndexOf('.'));
sanitizedFilename = nameWithoutExt.substring(0, 255 - ext.length - 1) + '.' + ext;
}
// Final sanitization
sanitizedFilename = sanitizeString(sanitizedFilename, 255);
CSRF Protection
OAuth State Tokens
OAuth flows use state tokens for CSRF protection:
// Generate secure state token
const state = generateOAuthState(); // 32 bytes random
const expiresAt = Math.floor(Date.now() / 1000) + 600; // 10 minutes
// Store in database
await env.DB.prepare(
'INSERT INTO oauth_states (state, expires_at, created_at) VALUES (?, ?, ?)'
)
.bind(state, expiresAt, Math.floor(Date.now() / 1000))
.run();
// Verify on callback
const storedState = await env.DB.prepare(
'SELECT state FROM oauth_states WHERE state = ? AND expires_at > ?'
)
.bind(state, Math.floor(Date.now() / 1000))
.first();
if (!storedState || !verifyOAuthState(state, storedState.state)) {
return new Response(
JSON.stringify(createErrorResponse('INVALID_STATE', 'Invalid or expired state')),
{ status: 400 }
);
}
// Delete after use (one-time use)
await env.DB.prepare('DELETE FROM oauth_states WHERE state = ?')
.bind(state)
.run();
Password Handling
Password Hashing
Always use hashPassword() function:
import { hashPassword } from '../auth/password';
const passwordHash = await hashPassword(password);
// Uses PBKDF2 with 100,000 iterations, SHA-256, 32-byte salt
Password Validation
Always use validatePassword():
import { validatePassword } from '../utils/validation';
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return new Response(
JSON.stringify(createErrorResponse('VALIDATION_ERROR', passwordValidation.error)),
{ status: 400 }
);
}
Never Log Passwords
// ❌ BAD
console.log('Password:', password);
// ✅ GOOD
// Don't log passwords at all
// If logging changes, redact passwords:
changes.password = '[REDACTED]';
File Upload Security
MIME Type Validation
const allowedMimeTypes = [
'image/jpeg', 'image/png', 'image/gif',
'application/pdf',
'text/plain', 'text/csv',
// ... more types
];
if (!allowedMimeTypes.includes(file.type)) {
return new Response(
JSON.stringify(createErrorResponse('VALIDATION_ERROR', 'File type not allowed')),
{ status: 400 }
);
}
File Size Validation
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
if (file.size > MAX_FILE_SIZE) {
return new Response(
JSON.stringify(createErrorResponse('VALIDATION_ERROR', 'File too large')),
{ status: 400 }
);
}
if (file.size === 0) {
return new Response(
JSON.stringify(createErrorResponse('VALIDATION_ERROR', 'File cannot be empty')),
{ status: 400 }
);
}
Extension Validation
const allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'txt', 'csv'];
const fileExtension = file.name.split('.').pop()?.toLowerCase();
if (!fileExtension || !allowedExtensions.includes(fileExtension)) {
return new Response(
JSON.stringify(createErrorResponse('VALIDATION_ERROR', 'File extension not allowed')),
{ status: 400 }
);
}
R2 Key Security
// Generate secure R2 key (no user input)
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 15);
const extension = sanitizedFilename.split('.').pop() || '';
const r2Key = `${fileType}/${timestamp}-${random}.${extension}`;
Public Token Security
Token Generation
function generatePublicToken(): string {
// Generate 32 bytes (256 bits) of random data
const bytes = crypto.getRandomValues(new Uint8Array(32));
// Convert to base64url (URL-safe)
const base64 = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return base64; // 43 characters
}
Token Uniqueness
let token: string;
let attempts = 0;
const maxAttempts = 10;
do {
token = generatePublicToken();
const existing = await env.DB.prepare(
'SELECT id FROM files WHERE public_token = ?'
)
.bind(token)
.first();
if (!existing) {
break; // Token is unique
}
attempts++;
if (attempts >= maxAttempts) {
return new Response(
JSON.stringify(createErrorResponse('INTERNAL_ERROR', 'Failed to generate unique token')),
{ status: 500 }
);
}
} while (true);
Error Message Sanitization
Never Expose Sensitive Information
❌ BAD:
return new Response(
JSON.stringify(createErrorResponse('ERROR', `SQL error: ${error.message}`)),
{ status: 500 }
);
✅ GOOD:
console.error('Database error:', error); // Log full error server-side
return new Response(
JSON.stringify(createErrorResponse('INTERNAL_ERROR', 'An error occurred. Please try again.')),
{ status: 500 }
);
User-Friendly Messages
// Generic, user-friendly messages
createErrorResponse('VALIDATION_ERROR', 'Invalid input')
createErrorResponse('NOT_FOUND', 'Resource not found')
createErrorResponse('UNAUTHORIZED', 'Authentication required')
createErrorResponse('FORBIDDEN', 'Insufficient permissions')
createErrorResponse('INTERNAL_ERROR', 'An error occurred. Please try again.')
Rate Limiting
Rate limiting is implemented via middleware:
import { publicRateLimit, authenticatedRateLimit, fileUploadRateLimit } from '../middleware/rate-limit';
// Public endpoints (login, bootstrap)
routes.push({
method: 'POST',
path: '/auth/login',
handler: async (request, env) => { /* ... */ },
middleware: [publicRateLimit], // 200 requests per minute per IP
});
// Authenticated endpoints
routes.push({
method: 'GET',
path: '/items',
handler: async (request, env) => { /* ... */ },
middleware: [requireAuth(), authenticatedRateLimit], // 2000 requests per minute per user
});
// File upload
routes.push({
method: 'POST',
path: '/files/upload',
handler: async (request, env) => { /* ... */ },
middleware: [requireAuth(), fileUploadRateLimit], // 200 requests per minute per user
});
Role-Based Access Control
Always Use Middleware
import { requireAuth, requireRole } from '../middleware/auth';
// Require authentication
routes.push({
method: 'GET',
path: '/protected',
handler: async (request, env) => {
const auth = (request as any).auth as AuthContext;
// auth.userId, auth.email, auth.role available
},
middleware: [requireAuth()],
});
// Require specific role
routes.push({
method: 'DELETE',
path: '/items/:id',
handler: async (request, env) => { /* ... */ },
middleware: [requireRole('Admin')], // Only Admin
});
// Require multiple roles
routes.push({
method: 'POST',
path: '/projects',
handler: async (request, env) => { /* ... */ },
middleware: [requireRole('Producer', 'Admin')], // Producer or Admin
});
Audit Logging
Log Important Actions
const now = Math.floor(Date.now() / 1000);
await env.DB.prepare(
'INSERT INTO audit_log (entity_type, entity_id, action, user_id, changes, timestamp) VALUES (?, ?, ?, ?, ?, ?)'
)
.bind('item', id, 'UPDATE', auth.userId, JSON.stringify(changes), now)
.run();
Redact Sensitive Data
const changes: Record<string, any> = { name: newName };
if (password !== undefined) {
changes.password = '[REDACTED]'; // Never log actual passwords
}
await env.DB.prepare(
'INSERT INTO audit_log (entity_type, entity_id, action, user_id, changes, timestamp) VALUES (?, ?, ?, ?, ?, ?)'
)
.bind('user', userId, 'UPDATE', auth.userId, JSON.stringify(changes), now)
.run();
Best Practices Summary
- Always validate input - Use validation functions
- Always sanitize strings - Use
sanitizeString()before storage - Always use prepared statements - Never concatenate SQL
- Always use middleware - For authentication and authorization
- Always log important actions - Use audit log
- Never log sensitive data - Redact passwords, tokens
- Never expose internal errors - Use generic user-friendly messages
- Always use rate limiting - Protect against abuse
- Always validate file uploads - MIME type, size, extension
- Always use secure random - For tokens, challenges, secrets
Related Topics
- API Development - API development patterns
- Frontend Development - Frontend security
- Authentication - Auth implementation details
- Database - Database security considerations