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