Security - RumenDamyanov/js-chess GitHub Wiki
Comprehensive security guide for the chess showcase application covering frontend, backend, and infrastructure security.
Security considerations for this chess application include:
- Frontend security (XSS, CSRF, data validation)
- API security and authentication
- WebSocket connection security
- Data protection and privacy
- Infrastructure and deployment security
- Real-time communication security
- User input validation and sanitization
// shared/security/InputSanitizer.js
export class InputSanitizer {
constructor() {
this.htmlEntities = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/'
};
}
sanitizeHtml(input) {
if (typeof input !== 'string') return '';
return input.replace(/[&<>"'/]/g, (char) => {
return this.htmlEntities[char] || char;
});
}
sanitizeChessMove(moveNotation) {
// Chess moves should only contain specific characters
const validChars = /^[a-h1-8NBRQK+#=\-x0O]+$/;
if (!validChars.test(moveNotation)) {
throw new Error('Invalid chess notation');
}
return moveNotation.substring(0, 10); // Limit length
}
sanitizeUsername(username) {
// Remove potentially harmful characters
const cleaned = username
.replace(/[<>"\\/]/g, '')
.substring(0, 50)
.trim();
if (cleaned.length < 2) {
throw new Error('Username too short');
}
return cleaned;
}
sanitizeChatMessage(message) {
if (typeof message !== 'string') return '';
// Remove HTML tags and limit length
const cleaned = message
.replace(/<[^>]*>/g, '')
.substring(0, 500)
.trim();
return this.sanitizeHtml(cleaned);
}
validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email) || email.length > 254) {
throw new Error('Invalid email format');
}
return email.toLowerCase();
}
}
export const inputSanitizer = new InputSanitizer();
<!-- CSP implementation in HTML -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self' ws: wss:;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
">
// CSP configuration for Express.js backend
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "ws:", "wss:"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"]
}
},
crossOriginEmbedderPolicy: false // Disable for WebSocket support
}));
Angular:
// Angular automatic XSS protection
import { Component } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Component({
selector: 'app-chat-message',
template: `
<!-- Angular automatically sanitizes this -->
<div class="message">{{ message.text }}</div>
<!-- For trusted HTML (use sparingly) -->
<div [innerHTML]="trustedHtml"></div>
`
})
export class ChatMessageComponent {
message: any;
trustedHtml: SafeHtml;
constructor(private sanitizer: DomSanitizer) {}
setTrustedContent(html: string) {
// Only use for content you absolutely trust
this.trustedHtml = this.sanitizer.bypassSecurityTrustHtml(html);
}
}
React:
// React XSS prevention
import DOMPurify from 'dompurify';
function ChatMessage({ message }) {
// React automatically escapes content in JSX
return (
<div className="message">
{message.text}
</div>
);
}
function TrustedHtmlMessage({ htmlContent }) {
// For HTML content that needs to be rendered
const sanitizedHtml = DOMPurify.sanitize(htmlContent);
return (
<div
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
/>
);
}
Vue:
<template>
<div class="message">
<!-- Vue automatically escapes this -->
{{ message.text }}
<!-- For trusted HTML -->
<div v-html="sanitizedHtml"></div>
</div>
</template>
<script setup>
import DOMPurify from 'dompurify';
import { computed } from 'vue';
const props = defineProps(['message']);
const sanitizedHtml = computed(() => {
return DOMPurify.sanitize(props.message.html || '');
});
</script>
// shared/security/CsrfProtection.js
export class CsrfProtection {
constructor() {
this.token = this.generateToken();
this.tokenName = 'X-CSRF-Token';
}
generateToken() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
getToken() {
return this.token;
}
addTokenToRequest(request) {
if (request.method !== 'GET') {
request.headers = request.headers || {};
request.headers[this.tokenName] = this.token;
}
return request;
}
validateToken(receivedToken) {
return this.token === receivedToken;
}
refreshToken() {
this.token = this.generateToken();
return this.token;
}
}
// Usage in API client
export class SecureApiClient {
constructor(baseURL) {
this.baseURL = baseURL;
this.csrfProtection = new CsrfProtection();
}
async request(method, endpoint, data = null) {
const config = {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfProtection.getToken()
},
credentials: 'same-origin' // Include cookies
};
if (data) {
config.body = JSON.stringify(data);
}
const response = await fetch(this.baseURL + endpoint, config);
// Handle CSRF token refresh
if (response.status === 403) {
const newToken = response.headers.get('X-CSRF-Token');
if (newToken) {
this.csrfProtection.token = newToken;
// Retry request with new token
config.headers['X-CSRF-Token'] = newToken;
return fetch(this.baseURL + endpoint, config);
}
}
return response;
}
}
// shared/auth/TokenManager.js
export class TokenManager {
constructor() {
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiry = null;
this.refreshThreshold = 5 * 60 * 1000; // 5 minutes before expiry
}
setTokens(accessToken, refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
// Decode token to get expiry
try {
const payload = JSON.parse(atob(accessToken.split('.')[1]));
this.tokenExpiry = payload.exp * 1000; // Convert to milliseconds
} catch (error) {
console.error('Invalid token format:', error);
}
this.saveToStorage();
}
getAccessToken() {
if (this.isTokenExpired()) {
return null;
}
return this.accessToken;
}
isTokenExpired() {
if (!this.tokenExpiry) return true;
return Date.now() >= this.tokenExpiry;
}
shouldRefresh() {
if (!this.tokenExpiry) return false;
return Date.now() >= (this.tokenExpiry - this.refreshThreshold);
}
async refreshAccessToken() {
if (!this.refreshToken) {
throw new Error('No refresh token available');
}
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken: this.refreshToken })
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const { accessToken, refreshToken } = await response.json();
this.setTokens(accessToken, refreshToken);
return accessToken;
} catch (error) {
this.clearTokens();
throw error;
}
}
saveToStorage() {
try {
const tokenData = {
accessToken: this.accessToken,
refreshToken: this.refreshToken,
tokenExpiry: this.tokenExpiry
};
// Use sessionStorage for temporary tokens or localStorage for persistent
localStorage.setItem('auth-tokens', JSON.stringify(tokenData));
} catch (error) {
console.error('Failed to save tokens:', error);
}
}
loadFromStorage() {
try {
const stored = localStorage.getItem('auth-tokens');
if (stored) {
const tokenData = JSON.parse(stored);
this.accessToken = tokenData.accessToken;
this.refreshToken = tokenData.refreshToken;
this.tokenExpiry = tokenData.tokenExpiry;
}
} catch (error) {
console.error('Failed to load tokens:', error);
this.clearTokens();
}
}
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiry = null;
try {
localStorage.removeItem('auth-tokens');
} catch (error) {
console.error('Failed to clear tokens:', error);
}
}
}
export const tokenManager = new TokenManager();
// shared/auth/AuthInterceptor.js
export class AuthInterceptor {
constructor(tokenManager, apiClient) {
this.tokenManager = tokenManager;
this.apiClient = apiClient;
this.isRefreshing = false;
this.refreshPromise = null;
}
async interceptRequest(request) {
// Check if token needs refresh
if (this.tokenManager.shouldRefresh() && !this.isRefreshing) {
await this.refreshToken();
}
// Add authorization header
const token = this.tokenManager.getAccessToken();
if (token) {
request.headers = request.headers || {};
request.headers['Authorization'] = `Bearer ${token}`;
}
return request;
}
async interceptResponse(response, request) {
// Handle 401 unauthorized
if (response.status === 401) {
try {
await this.refreshToken();
// Retry original request with new token
const newToken = this.tokenManager.getAccessToken();
if (newToken) {
request.headers['Authorization'] = `Bearer ${newToken}`;
return fetch(request.url, request);
}
} catch (error) {
// Refresh failed, redirect to login
this.handleAuthenticationFailure();
throw error;
}
}
return response;
}
async refreshToken() {
if (this.isRefreshing) {
return this.refreshPromise;
}
this.isRefreshing = true;
this.refreshPromise = this.tokenManager.refreshAccessToken()
.finally(() => {
this.isRefreshing = false;
this.refreshPromise = null;
});
return this.refreshPromise;
}
handleAuthenticationFailure() {
this.tokenManager.clearTokens();
// Emit authentication failure event
const event = new CustomEvent('authenticationFailure');
document.dispatchEvent(event);
// Redirect to login (adjust based on your routing)
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
}
// shared/websocket/SecureWebSocket.js
export class SecureWebSocket {
constructor(url, tokenManager, options = {}) {
this.url = url;
this.tokenManager = tokenManager;
this.options = {
maxMessageSize: 64 * 1024, // 64KB max message size
heartbeatInterval: 30000,
authTimeout: 5000,
...options
};
this.ws = null;
this.isAuthenticated = false;
this.messageQueue = [];
this.heartbeatTimer = null;
this.rateLimiter = new RateLimiter(100, 60000); // 100 messages per minute
}
async connect() {
const token = this.tokenManager.getAccessToken();
if (!token) {
throw new Error('No authentication token available');
}
// Use secure WebSocket protocol
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}${this.url}`;
this.ws = new WebSocket(wsUrl);
return new Promise((resolve, reject) => {
const authTimeout = setTimeout(() => {
this.ws.close();
reject(new Error('WebSocket authentication timeout'));
}, this.options.authTimeout);
this.ws.onopen = () => {
// Send authentication token
this.send({
type: 'auth',
token: token
});
};
this.ws.onmessage = (event) => {
const message = this.parseMessage(event.data);
if (message.type === 'auth_success') {
clearTimeout(authTimeout);
this.isAuthenticated = true;
this.startHeartbeat();
this.flushMessageQueue();
resolve();
} else if (message.type === 'auth_failed') {
clearTimeout(authTimeout);
this.ws.close();
reject(new Error('WebSocket authentication failed'));
} else if (this.isAuthenticated) {
this.handleMessage(message);
}
};
this.ws.onclose = () => {
clearTimeout(authTimeout);
this.stopHeartbeat();
this.isAuthenticated = false;
};
this.ws.onerror = (error) => {
clearTimeout(authTimeout);
reject(error);
};
});
}
send(message) {
// Rate limiting
if (!this.rateLimiter.allow()) {
throw new Error('Rate limit exceeded');
}
// Message size validation
const messageStr = JSON.stringify(message);
if (messageStr.length > this.options.maxMessageSize) {
throw new Error('Message too large');
}
if (!this.isAuthenticated) {
this.messageQueue.push(message);
return;
}
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(messageStr);
}
}
parseMessage(data) {
try {
const message = JSON.parse(data);
// Validate message structure
if (typeof message !== 'object' || !message.type) {
throw new Error('Invalid message format');
}
return message;
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
return null;
}
}
handleMessage(message) {
// Validate message types
const allowedTypes = [
'game_update', 'move', 'chat', 'heartbeat_response',
'player_joined', 'player_left', 'game_ended'
];
if (!allowedTypes.includes(message.type)) {
console.warn('Unknown message type:', message.type);
return;
}
// Emit message to handlers
this.emit('message', message);
}
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.send({ type: 'heartbeat' });
}
}, this.options.heartbeatInterval);
}
stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
flushMessageQueue() {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
this.send(message);
}
}
close() {
this.stopHeartbeat();
if (this.ws) {
this.ws.close();
}
}
}
// Rate limiter utility
class RateLimiter {
constructor(maxRequests, windowMs) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
this.requests = [];
}
allow() {
const now = Date.now();
// Remove old requests outside the window
this.requests = this.requests.filter(time => now - time < this.windowMs);
if (this.requests.length >= this.maxRequests) {
return false;
}
this.requests.push(now);
return true;
}
}
// shared/security/ChessMoveValidator.js
export class ChessMoveValidator {
constructor() {
this.pieceTypes = ['p', 'n', 'b', 'r', 'q', 'k'];
this.files = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
this.ranks = ['1', '2', '3', '4', '5', '6', '7', '8'];
}
validateMove(move, gameState) {
// Validate move object structure
if (!this.isValidMoveObject(move)) {
throw new SecurityError('Invalid move object structure');
}
// Validate move notation
if (!this.isValidNotation(move.notation)) {
throw new SecurityError('Invalid move notation');
}
// Validate board positions
if (!this.isValidSquare(move.from) || !this.isValidSquare(move.to)) {
throw new SecurityError('Invalid board positions');
}
// Validate against game rules
if (!this.isLegalMove(move, gameState)) {
throw new SecurityError('Illegal chess move');
}
return true;
}
isValidMoveObject(move) {
return (
typeof move === 'object' &&
typeof move.from === 'string' &&
typeof move.to === 'string' &&
typeof move.notation === 'string' &&
(move.promotion === undefined || this.pieceTypes.includes(move.promotion))
);
}
isValidNotation(notation) {
// Standard algebraic notation patterns
const patterns = [
/^[NBRQK]?[a-h]?[1-8]?x?[a-h][1-8](\=[NBRQ])?[\+\#]?$/, // Normal moves
/^O-O(-O)?[\+\#]?$/, // Castling
/^[a-h]x[a-h][1-8](\s*e\.p\.)?[\+\#]?$/ // En passant
];
return patterns.some(pattern => pattern.test(notation));
}
isValidSquare(square) {
if (typeof square !== 'string' || square.length !== 2) {
return false;
}
const [file, rank] = square;
return this.files.includes(file) && this.ranks.includes(rank);
}
isLegalMove(move, gameState) {
// This should integrate with your chess engine
// for full legal move validation
try {
const chesEngine = new ChessEngine(gameState);
return chesEngine.isLegalMove(move);
} catch (error) {
return false;
}
}
}
class SecurityError extends Error {
constructor(message) {
super(message);
this.name = 'SecurityError';
}
}
export const chessMoveValidator = new ChessMoveValidator();
// shared/security/InputValidator.js
export class InputValidator {
static validateGameId(gameId) {
// Game IDs should be UUIDs
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(gameId)) {
throw new ValidationError('Invalid game ID format');
}
return gameId;
}
static validateUserId(userId) {
if (typeof userId !== 'string' || userId.length < 1 || userId.length > 50) {
throw new ValidationError('Invalid user ID');
}
// Only allow alphanumeric characters and hyphens
if (!/^[a-zA-Z0-9\-]+$/.test(userId)) {
throw new ValidationError('Invalid user ID characters');
}
return userId;
}
static validateTimeControl(timeControl) {
if (typeof timeControl !== 'object') {
throw new ValidationError('Time control must be an object');
}
const { initial, increment } = timeControl;
if (typeof initial !== 'number' || initial < 0 || initial > 7200000) { // Max 2 hours
throw new ValidationError('Invalid initial time');
}
if (typeof increment !== 'number' || increment < 0 || increment > 300000) { // Max 5 minutes
throw new ValidationError('Invalid increment time');
}
return timeControl;
}
static validateGameSettings(settings) {
const allowedSettings = [
'timeControl', 'rated', 'color', 'aiLevel', 'variant'
];
const validatedSettings = {};
for (const [key, value] of Object.entries(settings)) {
if (!allowedSettings.includes(key)) {
throw new ValidationError(`Unknown setting: ${key}`);
}
switch (key) {
case 'timeControl':
validatedSettings[key] = this.validateTimeControl(value);
break;
case 'rated':
if (typeof value !== 'boolean') {
throw new ValidationError('Rated setting must be boolean');
}
validatedSettings[key] = value;
break;
case 'color':
if (!['white', 'black', 'random'].includes(value)) {
throw new ValidationError('Invalid color preference');
}
validatedSettings[key] = value;
break;
case 'aiLevel':
if (typeof value !== 'number' || value < 1 || value > 10) {
throw new ValidationError('AI level must be between 1 and 10');
}
validatedSettings[key] = value;
break;
case 'variant':
if (!['standard', 'chess960', 'kingofthehill'].includes(value)) {
throw new ValidationError('Invalid chess variant');
}
validatedSettings[key] = value;
break;
}
}
return validatedSettings;
}
}
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}
// shared/security/HttpsEnforcer.js
export class HttpsEnforcer {
static enforceHttps() {
if (typeof window !== 'undefined' &&
location.protocol !== 'https:' &&
location.hostname !== 'localhost' &&
!location.hostname.startsWith('127.')) {
// Redirect to HTTPS
location.replace(`https:${location.href.substring(location.protocol.length)}`);
}
}
static addSecurityHeaders() {
// This should be implemented on the server side
return {
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'strict-origin-when-cross-origin'
};
}
}
// Auto-enforce HTTPS on load
if (typeof window !== 'undefined') {
HttpsEnforcer.enforceHttps();
}
// Express.js security middleware
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const cors = require('cors');
const app = express();
// Security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "wss:"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
},
},
crossOriginEmbedderPolicy: false,
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
}));
// CORS configuration
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true,
optionsSuccessStatus: 200
}));
// Rate limiting
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP',
standardHeaders: true,
legacyHeaders: false
});
app.use('/api/', apiLimiter);
// Stricter rate limiting for authentication endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // limit each IP to 5 requests per windowMs
skipSuccessfulRequests: true
});
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
// shared/config/SecurityConfig.js
export class SecurityConfig {
constructor() {
this.requiredEnvVars = [
'JWT_SECRET',
'DATABASE_URL',
'REDIS_URL',
'API_BASE_URL'
];
this.validate();
}
validate() {
const missing = this.requiredEnvVars.filter(
varName => !process.env[varName]
);
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
// Validate JWT secret strength
if (process.env.JWT_SECRET.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters long');
}
}
getClientConfig() {
// Only expose safe configuration to the client
return {
apiBaseUrl: process.env.API_BASE_URL,
wsUrl: process.env.WS_URL,
environment: process.env.NODE_ENV,
version: process.env.APP_VERSION
};
}
getServerConfig() {
return {
jwtSecret: process.env.JWT_SECRET,
databaseUrl: process.env.DATABASE_URL,
redisUrl: process.env.REDIS_URL,
port: parseInt(process.env.PORT || '3000', 10)
};
}
}
export const securityConfig = new SecurityConfig();
# docker-compose.yml with secrets
version: '3.8'
services:
app:
build: .
secrets:
- jwt_secret
- db_password
environment:
- JWT_SECRET_FILE=/run/secrets/jwt_secret
- DB_PASSWORD_FILE=/run/secrets/db_password
secrets:
jwt_secret:
file: ./secrets/jwt_secret.txt
db_password:
file: ./secrets/db_password.txt
// Utility to read secrets from files
import fs from 'fs';
export function readSecret(envVar, fallback = null) {
const secretFile = process.env[`${envVar}_FILE`];
if (secretFile && fs.existsSync(secretFile)) {
return fs.readFileSync(secretFile, 'utf8').trim();
}
return process.env[envVar] || fallback;
}
// Usage
const jwtSecret = readSecret('JWT_SECRET');
const dbPassword = readSecret('DB_PASSWORD');
// shared/security/SecurityLogger.js
export class SecurityLogger {
constructor() {
this.events = new Map();
this.alertThresholds = {
failed_login: 5,
invalid_token: 10,
rate_limit_exceeded: 3,
suspicious_move: 10
};
}
logSecurityEvent(type, details, userId = null, ipAddress = null) {
const event = {
type,
details,
userId,
ipAddress,
timestamp: new Date().toISOString(),
userAgent: typeof window !== 'undefined' ? navigator.userAgent : null
};
console.warn('Security Event:', event);
// Track event frequency
this.trackEventFrequency(type, userId || ipAddress);
// Send to monitoring service
this.sendToMonitoring(event);
}
trackEventFrequency(type, identifier) {
if (!identifier) return;
const key = `${type}:${identifier}`;
const currentCount = this.events.get(key) || 0;
const newCount = currentCount + 1;
this.events.set(key, newCount);
// Check if threshold exceeded
const threshold = this.alertThresholds[type];
if (threshold && newCount >= threshold) {
this.triggerAlert(type, identifier, newCount);
}
// Clean up old events (implement TTL)
this.cleanupOldEvents();
}
triggerAlert(type, identifier, count) {
const alert = {
level: 'HIGH',
type: 'SECURITY_THRESHOLD_EXCEEDED',
eventType: type,
identifier,
count,
timestamp: new Date().toISOString()
};
console.error('Security Alert:', alert);
// Send alert to monitoring system
this.sendAlert(alert);
}
sendToMonitoring(event) {
// Send to external monitoring service
if (typeof fetch !== 'undefined') {
fetch('/api/security/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event)
}).catch(error => {
console.error('Failed to send security event:', error);
});
}
}
sendAlert(alert) {
// Send to alerting system (email, Slack, etc.)
if (typeof fetch !== 'undefined') {
fetch('/api/security/alerts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(alert)
}).catch(error => {
console.error('Failed to send security alert:', error);
});
}
}
cleanupOldEvents() {
// Simple cleanup - in production, implement proper TTL
if (this.events.size > 1000) {
const entries = Array.from(this.events.entries());
entries.slice(0, 500).forEach(([key]) => {
this.events.delete(key);
});
}
}
}
export const securityLogger = new SecurityLogger();
// shared/security/IntrusionDetection.js
export class IntrusionDetection {
constructor() {
this.suspiciousPatterns = {
rapidMoves: { threshold: 10, timeWindow: 60000 }, // 10 moves in 1 minute
impossibleMoves: { threshold: 3, timeWindow: 300000 }, // 3 invalid moves in 5 minutes
tokenAbuse: { threshold: 5, timeWindow: 60000 }, // 5 token errors in 1 minute
apiAbuse: { threshold: 100, timeWindow: 60000 } // 100 API calls in 1 minute
};
this.userActivity = new Map();
}
checkForSuspiciousActivity(userId, activityType, metadata = {}) {
const activity = this.getUserActivity(userId);
const now = Date.now();
// Add new activity
activity.push({
type: activityType,
timestamp: now,
metadata
});
// Clean old activities
this.cleanOldActivities(activity, now);
// Check for patterns
return this.analyzeActivity(userId, activity, activityType);
}
getUserActivity(userId) {
if (!this.userActivity.has(userId)) {
this.userActivity.set(userId, []);
}
return this.userActivity.get(userId);
}
cleanOldActivities(activities, currentTime) {
const maxAge = Math.max(...Object.values(this.suspiciousPatterns).map(p => p.timeWindow));
while (activities.length > 0 && currentTime - activities[0].timestamp > maxAge) {
activities.shift();
}
}
analyzeActivity(userId, activities, currentActivityType) {
const threats = [];
for (const [patternName, pattern] of Object.entries(this.suspiciousPatterns)) {
const relevantActivities = this.getRelevantActivities(activities, patternName, pattern.timeWindow);
if (relevantActivities.length >= pattern.threshold) {
threats.push({
type: patternName,
count: relevantActivities.length,
threshold: pattern.threshold,
timeWindow: pattern.timeWindow,
severity: this.calculateSeverity(relevantActivities.length, pattern.threshold)
});
}
}
if (threats.length > 0) {
this.handleThreats(userId, threats, currentActivityType);
}
return threats;
}
getRelevantActivities(activities, patternName, timeWindow) {
const now = Date.now();
const cutoff = now - timeWindow;
return activities.filter(activity => {
if (activity.timestamp < cutoff) return false;
switch (patternName) {
case 'rapidMoves':
return activity.type === 'move';
case 'impossibleMoves':
return activity.type === 'invalid_move';
case 'tokenAbuse':
return activity.type === 'token_error';
case 'apiAbuse':
return activity.type === 'api_call';
default:
return false;
}
});
}
calculateSeverity(count, threshold) {
const ratio = count / threshold;
if (ratio >= 3) return 'CRITICAL';
if (ratio >= 2) return 'HIGH';
if (ratio >= 1.5) return 'MEDIUM';
return 'LOW';
}
handleThreats(userId, threats, activityType) {
const maxSeverity = threats.reduce((max, threat) => {
const severityOrder = { 'LOW': 1, 'MEDIUM': 2, 'HIGH': 3, 'CRITICAL': 4 };
return severityOrder[threat.severity] > severityOrder[max] ? threat.severity : max;
}, 'LOW');
const threatDetails = {
userId,
threats,
maxSeverity,
timestamp: new Date().toISOString(),
triggerActivity: activityType
};
securityLogger.logSecurityEvent('intrusion_detected', threatDetails, userId);
// Take action based on severity
this.takeAction(userId, maxSeverity, threatDetails);
}
takeAction(userId, severity, threatDetails) {
switch (severity) {
case 'CRITICAL':
this.blockUser(userId, 'Automatic block due to critical security threat');
break;
case 'HIGH':
this.temporaryRestriction(userId, 300000); // 5 minutes
break;
case 'MEDIUM':
this.increaseMonitoring(userId);
break;
case 'LOW':
// Just log, no action
break;
}
}
blockUser(userId, reason) {
// Implement user blocking logic
console.error(`Blocking user ${userId}: ${reason}`);
// Send to backend to block user
if (typeof fetch !== 'undefined') {
fetch('/api/security/block-user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, reason })
});
}
}
temporaryRestriction(userId, duration) {
console.warn(`Temporary restriction for user ${userId} for ${duration}ms`);
// Implement temporary restriction logic
setTimeout(() => {
console.log(`Lifting restriction for user ${userId}`);
}, duration);
}
increaseMonitoring(userId) {
console.log(`Increasing monitoring for user ${userId}`);
// Implement increased monitoring logic
}
}
export const intrusionDetection = new IntrusionDetection();
// tests/security/SecurityTests.js
import { describe, it, expect } from 'vitest';
import { inputSanitizer } from '../../shared/security/InputSanitizer.js';
import { chessMoveValidator } from '../../shared/security/ChessMoveValidator.js';
describe('Security Tests', () => {
describe('Input Sanitization', () => {
it('should sanitize HTML content', () => {
const maliciousInput = '<script>alert("XSS")</script>';
const sanitized = inputSanitizer.sanitizeHtml(maliciousInput);
expect(sanitized).not.toContain('<script>');
expect(sanitized).toBe('<script>alert("XSS")</script>');
});
it('should validate chess move notation', () => {
const validMoves = ['e4', 'Nf3', 'O-O', 'Qh5+', 'Rxe8#'];
const invalidMoves = ['<script>', 'eval()', 'a9', 'Z1'];
validMoves.forEach(move => {
expect(() => inputSanitizer.sanitizeChessMove(move)).not.toThrow();
});
invalidMoves.forEach(move => {
expect(() => inputSanitizer.sanitizeChessMove(move)).toThrow();
});
});
it('should sanitize usernames', () => {
expect(inputSanitizer.sanitizeUsername('ValidUser')).toBe('ValidUser');
expect(inputSanitizer.sanitizeUsername('User<script>')).toBe('UserScript()');
expect(() => inputSanitizer.sanitizeUsername('x')).toThrow();
});
});
describe('Chess Move Validation', () => {
it('should validate move object structure', () => {
const validMove = {
from: 'e2',
to: 'e4',
notation: 'e4'
};
const invalidMoves = [
{ from: 'e2' }, // Missing 'to'
{ from: 'z1', to: 'e4', notation: 'e4' }, // Invalid square
{ from: 'e2', to: 'e4', notation: '<script>' } // Invalid notation
];
expect(chessMoveValidator.isValidMoveObject(validMove)).toBe(true);
invalidMoves.forEach(move => {
expect(chessMoveValidator.isValidMoveObject(move)).toBe(false);
});
});
});
describe('Token Security', () => {
it('should detect expired tokens', () => {
const expiredToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDAwMDAwMDB9.x';
// Test token expiry detection
});
it('should validate token format', () => {
const validToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
const invalidTokens = [
'invalid.token',
'',
null,
'not.a.jwt.token.at.all'
];
// Test token format validation
});
});
});
- Monitoring - Security monitoring and alerting
- Deployment Guide - Secure deployment practices
- Troubleshooting - Security issue resolution