Přeskočit na hlavní obsah

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 validation
  • validatePassword(password: string): { valid: boolean; error?: string } - Password strength (min 8 chars, max 128, common passwords check)
  • validateId(id: any): number | null - Positive integer ID validation
  • validateStringLength(str: string, min: number, max: number): boolean - String length validation
  • validateRole(role: any): role is 'Loader' | 'Producer' | 'Admin' - Role validation
  • validateTimestamp(timestamp: any): number | null - Unix timestamp validation
  • validatePhoneNumber(phone: string): boolean - Phone number validation
  • sanitizeString(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​

  1. Always validate input - Use validation functions
  2. Always sanitize strings - Use sanitizeString() before storage
  3. Always use prepared statements - Never concatenate SQL
  4. Always use middleware - For authentication and authorization
  5. Always log important actions - Use audit log
  6. Never log sensitive data - Redact passwords, tokens
  7. Never expose internal errors - Use generic user-friendly messages
  8. Always use rate limiting - Protect against abuse
  9. Always validate file uploads - MIME type, size, extension
  10. Always use secure random - For tokens, challenges, secrets