Security
Security Best Practices
Security guidelines for building secure applications with the Cloudillo API.
Authentication Security
Token Storage
Never store tokens insecurely:
// ❌ NEVER - tokens in cookies without httpOnly
document.cookie = `token=${token}`
// ❌ NEVER - tokens in URL
window.location.href = `/app?token=${token}`
// ❌ NEVER - tokens in localStorage on shared devices
// (acceptable for user-owned devices with understanding of risks)
localStorage.setItem('token', token)
// ✅ GOOD - sessionStorage for temporary sessions
sessionStorage.setItem('token', token)
// ✅ BETTER - in-memory storage only
class SecureTokenStore {
private token: string | null = null
setToken(token: string) {
this.token = token
}
getToken(): string | null {
return this.token
}
clearToken() {
this.token = null
}
}
// ✅ BEST - httpOnly secure cookies (set by server)
// Server sets: Set-Cookie: token=...; HttpOnly; Secure; SameSite=Strict
Token Transmission
Always use HTTPS in production:
// ❌ NEVER in production - unencrypted HTTP
const API_URL = 'http://api.example.com'
// ✅ ALWAYS in production - encrypted HTTPS
const API_URL = 'https://api.example.com'
// ✅ GOOD - enforce HTTPS
function enforceHttps() {
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
location.replace(`https:${location.href.substring(location.protocol.length)}`)
}
}Properly set Authorization header:
// ✅ Correct Authorization header format
fetch('/api/action', {
headers: {
'Authorization': `Bearer ${token}`
}
})
// ❌ NEVER log or expose tokens
console.log('Token:', token) // NO!
// ✅ Safe logging for debugging
console.log('Token present:', !!token)
console.log('Token length:', token?.length)Token Validation
Validate tokens before use:
function isTokenValid(token: string): boolean {
if (!token) return false
try {
// Parse JWT
const parts = token.split('.')
if (parts.length !== 3) return false
// Decode payload
const payload = JSON.parse(atob(parts[1]))
// Check expiration
const now = Math.floor(Date.now() / 1000)
if (payload.exp && payload.exp < now) {
return false
}
// Check issuer (if known)
if (payload.iss && !isValidIssuer(payload.iss)) {
return false
}
return true
} catch (err) {
return false
}
}
// Use before making requests
if (!isTokenValid(token)) {
// Redirect to login or refresh token
window.location.href = '/login'
}Token Refresh
Implement secure token refresh:
class SecureAuthManager {
private refreshInProgress: Promise<string> | null = null
async getValidToken(): Promise<string> {
const currentToken = this.getToken()
if (this.isExpiringSoon(currentToken)) {
// Prevent concurrent refresh attempts
if (!this.refreshInProgress) {
this.refreshInProgress = this.refreshToken(currentToken)
.finally(() => {
this.refreshInProgress = null
})
}
return this.refreshInProgress
}
return currentToken
}
private async refreshToken(oldToken: string): Promise<string> {
try {
const response = await fetch('/api/auth/login-token', {
headers: { 'Authorization': `Bearer ${oldToken}` }
})
if (!response.ok) {
throw new Error('Token refresh failed')
}
const { data } = await response.json()
this.setToken(data.token)
return data.token
} catch (err) {
// Clear invalid token
this.clearToken()
// Redirect to login
window.location.href = '/login'
throw err
}
}
private isExpiringSoon(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
const expiresAt = payload.exp * 1000
const now = Date.now()
const fiveMinutes = 5 * 60 * 1000
return expiresAt - now < fiveMinutes
} catch {
return true // Treat invalid tokens as expired
}
}
}Input Validation and Sanitization
Validate All User Input
Client-side validation (for UX):
function validateActionInput(input: any): ValidationResult {
const errors: string[] = []
// Check required fields
if (!input.type) {
errors.push('Action type is required')
}
// Validate action type
const validTypes = ['POST', 'COMMENT', 'REACT', 'CONNECT', 'FOLLOW',
'CONVERSATION', 'ACKNOWLEDGE', 'FILESHARE', 'STATISTICS']
if (input.type && !validTypes.includes(input.type)) {
errors.push(`Invalid action type: ${input.type}`)
}
// Validate content length
if (input.content?.text && input.content.text.length > 10000) {
errors.push('Content text too long (max 10,000 characters)')
}
// Validate audience
if (!input.audience || !Array.isArray(input.audience) || input.audience.length === 0) {
errors.push('At least one audience member required')
}
// Validate timestamp
if (input.published) {
const now = Math.floor(Date.now() / 1000)
if (input.published > now + 300) { // 5 min tolerance
errors.push('Published timestamp cannot be in the future')
}
if (input.published < now - 365 * 24 * 60 * 60) { // 1 year ago
errors.push('Published timestamp too far in the past')
}
}
// Validate idTag format
if (input.issuerTag && !validateIdTag(input.issuerTag)) {
errors.push('Invalid issuer idTag format')
}
return {
valid: errors.length === 0,
errors
}
}
function validateIdTag(idTag: string): boolean {
// Format: local@domain
const pattern = /^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i
return pattern.test(idTag)
}Remember: Server-side validation is mandatory
// ⚠️ Client-side validation is for UX only
// Always validate on the server!
// The Cloudillo server performs validation on all inputs
Sanitize HTML Content
Prevent XSS attacks:
import DOMPurify from 'dompurify'
// ✅ Sanitize user-generated HTML
function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_DATA_ATTR: false
})
}
// ✅ Render safely
function renderPost(post: Action) {
const sanitized = sanitizeHtml(post.content.html || '')
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />
}
// ❌ NEVER render unsanitized user content
function renderPostUnsafe(post: Action) {
return <div dangerouslySetInnerHTML={{ __html: post.content.html }} />
}Escape text content:
function escapeHtml(text: string): string {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// Or use a library
import escape from 'lodash/escape'
// ✅ Safe text rendering
function renderText(text: string) {
return <div>{text}</div> // React escapes automatically
}
// ✅ Manual escaping if needed
const escaped = escape(userInput)Prevent SQL/NoSQL Injection
Use parameterized queries (server-side):
// ✅ GOOD - Parameterized query (Cloudillo server does this)
let action = sqlx::query_as!(
Action,
"SELECT * FROM actions WHERE action_id = $1",
action_id
)
.fetch_one(&pool)
.await?;
// ❌ NEVER - String concatenation
let query = format!("SELECT * FROM actions WHERE action_id = '{}'", action_id);Client-side: Don’t construct queries
// ✅ GOOD - Use API endpoints
const action = await api.action.id(actionId).get()
// ❌ NEVER - Don't try to construct SQL from client
const query = `SELECT * FROM actions WHERE id = '${actionId}'`Cross-Site Request Forgery (CSRF)
Token-Based CSRF Protection
Cloudillo uses JWT tokens for authentication, which provides CSRF protection when:
- Tokens are stored in
Authorizationheader (not cookies) - Requests are made from same origin
// ✅ SAFE - Token in Authorization header
fetch('/api/action', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(actionData)
})
// ⚠️ Additional protection for cookie-based auth
// Add CSRF token if using cookies
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
fetch('/api/action', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken,
'Content-Type': 'application/json'
},
credentials: 'include', // Include cookies
body: JSON.stringify(actionData)
})SameSite Cookie Attribute
If using cookies (server sets these):
Set-Cookie: token=...; HttpOnly; Secure; SameSite=Strict// Server-side cookie configuration
// SameSite=Strict: Cookie only sent for same-site requests
// SameSite=Lax: Cookie sent for top-level navigation
// SameSite=None: Cookie sent for all requests (requires Secure)
Content Security Policy (CSP)
Configure CSP Headers
Server-side CSP configuration:
<!-- In HTML meta tag -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' wss://your-server.com;">Or via HTTP header (preferred):
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' wss://your-server.com;
frame-ancestors 'none';Report CSP Violations
Content-Security-Policy-Report-Only:
default-src 'self';
report-uri /csp-report// Handle CSP reports (server-side)
app.post('/csp-report', (req, res) => {
console.error('CSP Violation:', req.body)
// Log to monitoring service
res.status(204).end()
})Rate Limiting and DoS Prevention
Client-Side Rate Limiting
class RateLimiter {
private requests: number[] = []
private limit: number
private window: number // milliseconds
constructor(limit: number, window: number) {
this.limit = limit
this.window = window
}
canMakeRequest(): boolean {
const now = Date.now()
// Remove old requests outside window
this.requests = this.requests.filter(time => now - time < this.window)
return this.requests.length < this.limit
}
recordRequest() {
this.requests.push(Date.now())
}
async throttle(): Promise<void> {
if (!this.canMakeRequest()) {
const oldestRequest = this.requests[0]
const waitTime = this.window - (Date.now() - oldestRequest)
if (waitTime > 0) {
await new Promise(resolve => setTimeout(resolve, waitTime))
}
}
this.recordRequest()
}
}
// Usage
const limiter = new RateLimiter(100, 60000) // 100 requests per minute
async function makeApiCall() {
await limiter.throttle()
return fetch('/api/action')
}Handle Rate Limit Responses
async function fetchWithRateLimit(url: string, options?: RequestInit) {
const response = await fetch(url, options)
if (response.status === 429) {
// Rate limited
const retryAfter = response.headers.get('Retry-After')
const waitSeconds = retryAfter ? parseInt(retryAfter) : 60
console.warn(`Rate limited. Retry after ${waitSeconds} seconds`)
await new Promise(resolve => setTimeout(resolve, waitSeconds * 1000))
// Retry request
return fetchWithRateLimit(url, options)
}
return response
}File Upload Security
Validate File Types
const ALLOWED_IMAGE_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp'
]
const ALLOWED_DOCUMENT_TYPES = [
'application/pdf',
'text/plain',
'application/json'
]
function validateFileType(file: File, allowedTypes: string[]): boolean {
if (!allowedTypes.includes(file.type)) {
console.error(`Invalid file type: ${file.type}`)
return false
}
// Check file extension as additional validation
const ext = file.name.split('.').pop()?.toLowerCase()
const validExtensions = {
'image/jpeg': ['jpg', 'jpeg'],
'image/png': ['png'],
'image/gif': ['gif'],
'image/webp': ['webp'],
'application/pdf': ['pdf'],
'text/plain': ['txt'],
'application/json': ['json']
}
const allowedExts = validExtensions[file.type as keyof typeof validExtensions] || []
if (!ext || !allowedExts.includes(ext)) {
console.error(`Invalid file extension: ${ext}`)
return false
}
return true
}Validate File Size
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
function validateFileSize(file: File): boolean {
if (file.size > MAX_FILE_SIZE) {
console.error(`File too large: ${file.size} bytes (max: ${MAX_FILE_SIZE})`)
return false
}
return true
}
// Combined validation
function validateFile(file: File): ValidationResult {
const errors: string[] = []
if (!validateFileType(file, ALLOWED_IMAGE_TYPES)) {
errors.push('Invalid file type. Please upload an image.')
}
if (!validateFileSize(file)) {
errors.push(`File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB.`)
}
return {
valid: errors.length === 0,
errors
}
}Scan Files for Malware
// Server-side malware scanning (example with ClamAV)
// This should be done on the server, not client
/*
async function scanFile(filePath: string): Promise<boolean> {
const scanner = new ClamAV()
const result = await scanner.scanFile(filePath)
if (result.isInfected) {
console.error('Malware detected:', result.viruses)
await fs.unlink(filePath) // Delete infected file
return false
}
return true
}
*/
// Client can only do basic validation
// Real security must be server-side
Secure WebSocket Connections
Use WSS (WebSocket Secure)
// ✅ GOOD - Encrypted WebSocket
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${protocol}//${location.host}/ws/rtdb/${fileId}?token=${token}`)
// ❌ NEVER in production - Unencrypted WebSocket
const ws = new WebSocket(`ws://example.com/ws/rtdb/${fileId}`)Authenticate WebSocket Connections
class SecureWebSocket {
private ws: WebSocket | null = null
async connect(url: string) {
// Get fresh token
const token = await authManager.getValidToken()
// Include token in connection
this.ws = new WebSocket(`${url}?token=${token}`)
this.ws.onopen = () => {
console.log('WebSocket connected')
}
this.ws.onerror = (error) => {
console.error('WebSocket error:', error)
// Check if auth error (code 1008 = policy violation)
// Refresh token and reconnect
}
this.ws.onmessage = (event) => {
this.handleMessage(event.data)
}
}
private handleMessage(data: string) {
try {
const message = JSON.parse(data)
// Validate message structure
if (!this.isValidMessage(message)) {
console.error('Invalid message format')
return
}
// Process message
this.processMessage(message)
} catch (err) {
console.error('Failed to parse message:', err)
}
}
private isValidMessage(message: any): boolean {
return (
typeof message === 'object' &&
typeof message.type === 'string' &&
message.data !== undefined
)
}
}Validate WebSocket Messages
// Define allowed message types
const ALLOWED_MESSAGE_TYPES = ['update', 'delete', 'subscribe', 'unsubscribe']
function validateWebSocketMessage(message: any): boolean {
// Check type
if (!ALLOWED_MESSAGE_TYPES.includes(message.type)) {
console.error('Invalid message type:', message.type)
return false
}
// Validate based on type
switch (message.type) {
case 'update':
return typeof message.path === 'string' && message.data !== undefined
case 'delete':
return typeof message.path === 'string'
case 'subscribe':
case 'unsubscribe':
return typeof message.path === 'string'
default:
return false
}
}Secure Data Storage
Encrypt Sensitive Data
// Use Web Crypto API for encryption
async function encryptData(data: string, key: CryptoKey): Promise<string> {
const encoder = new TextEncoder()
const dataBuffer = encoder.encode(data)
const iv = crypto.getRandomValues(new Uint8Array(12))
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
dataBuffer
)
// Combine IV and encrypted data
const combined = new Uint8Array(iv.length + encrypted.byteLength)
combined.set(iv)
combined.set(new Uint8Array(encrypted), iv.length)
// Convert to base64
return btoa(String.fromCharCode(...combined))
}
async function decryptData(encryptedData: string, key: CryptoKey): Promise<string> {
// Convert from base64
const combined = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0))
// Extract IV and encrypted data
const iv = combined.slice(0, 12)
const data = combined.slice(12)
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
data
)
const decoder = new TextDecoder()
return decoder.decode(decrypted)
}
// Generate encryption key
async function generateKey(): Promise<CryptoKey> {
return await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true, // extractable
['encrypt', 'decrypt']
)
}Clear Sensitive Data
class SecureDataHandler {
private sensitiveData: string | null = null
setSensitiveData(data: string) {
this.sensitiveData = data
}
clearSensitiveData() {
if (this.sensitiveData) {
// Overwrite with random data before clearing
const length = this.sensitiveData.length
this.sensitiveData = Array(length)
.fill(0)
.map(() => String.fromCharCode(Math.random() * 256))
.join('')
// Clear
this.sensitiveData = null
}
}
// Clear on page unload
cleanup() {
window.addEventListener('beforeunload', () => {
this.clearSensitiveData()
})
}
}Security Headers
Set Appropriate Headers
Server should set these headers:
# Prevent MIME type sniffing
X-Content-Type-Options: nosniff
# Enable XSS protection
X-XSS-Protection: 1; mode=block
# Prevent clickjacking
X-Frame-Options: DENY
# Control referrer information
Referrer-Policy: strict-origin-when-cross-origin
# Enforce HTTPS
Strict-Transport-Security: max-age=31536000; includeSubDomains
# Permissions Policy
Permissions-Policy: geolocation=(), microphone=(), camera=()Audit Logging
Log Security Events
class SecurityAuditor {
async logAuthEvent(event: string, details: any) {
await fetch('/api/audit/log', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
event,
details,
timestamp: Math.floor(Date.now() / 1000),
userAgent: navigator.userAgent,
ip: await this.getClientIP()
})
})
}
private async getClientIP(): Promise<string> {
try {
const response = await fetch('https://api.ipify.org?format=json')
const { ip } = await response.json()
return ip
} catch {
return 'unknown'
}
}
}
// Log security-relevant events
auditor.logAuthEvent('login_success', { email: user.email })
auditor.logAuthEvent('login_failure', { email: attemptedEmail })
auditor.logAuthEvent('token_refresh', { tokenId: oldTokenId })
auditor.logAuthEvent('permission_denied', { resource, action })Dependency Security
Keep Dependencies Updated
# Check for vulnerabilities
npm audit
# Fix vulnerabilities automatically
npm audit fix
# Update dependencies
npm update
# Check for outdated packages
npm outdatedUse Dependency Scanning
# .github/workflows/security.yml
name: Security Scan
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run npm audit
run: npm audit --audit-level=high
- name: Run Snyk scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}Summary
Key security practices for Cloudillo applications:
- Authentication: Secure token storage, HTTPS only, proper validation
- Input Validation: Validate all user input, sanitize HTML, prevent injection
- CSRF Protection: Use Authorization header, SameSite cookies
- CSP: Configure Content Security Policy headers
- Rate Limiting: Implement client and server rate limiting
- File Upload: Validate type and size, scan for malware
- WebSocket: Use WSS, authenticate connections, validate messages
- Data Storage: Encrypt sensitive data, clear when done
- Security Headers: Set appropriate HTTP security headers
- Audit Logging: Log security events for monitoring
- Dependencies: Keep dependencies updated, scan for vulnerabilities
Security is a continuous process. Regularly review and update your security practices.