12. Безопасность в real-time
Real-time соединения создают новые векторы атак. Разберём угрозы и защиту.
Аутентификация WebSocket/Socket.io
Заголовок раздела «Аутентификация WebSocket/Socket.io»WebSocket не отправляет куки автоматически на кросс-доменных запросах. Нужна явная аутентификация:
// ❌ НЕПРАВИЛЬНО: нет проверкиio.on('connection', (socket) => { socket.on('send-message', (data) => { io.emit('message', data); // Кто угодно может писать! });});
// ✅ ПРАВИЛЬНО: middleware с JWTio.use(async (socket, next) => { const token = socket.handshake.auth.token || socket.handshake.headers.authorization?.replace('Bearer ', '');
if (!token) { return next(new Error('Unauthorized: токен не предоставлен')); }
try { const payload = jwt.verify(token, process.env.JWT_SECRET); socket.data.userId = payload.sub; socket.data.username = payload.username; socket.data.role = payload.role; next(); } catch (err) { next(new Error('Unauthorized: невалидный токен')); }});
// Клиентconst socket = io('wss://example.com', { auth: { token: localStorage.getItem('accessToken'), },});
// Обработка ошибки авторизацииsocket.on('connect_error', (err) => { if (err.message === 'Unauthorized: токен не предоставлен') { // Перенаправить на логин window.location.href = '/login'; }});Refresh Token для WebSocket
Заголовок раздела «Refresh Token для WebSocket»JWT истекают. Нужно обновлять токен без разрыва соединения:
// Клиентsocket.on('token_expired', async () => { try { const newToken = await refreshAccessToken(); socket.auth.token = newToken; socket.disconnect().connect(); // Переподключиться с новым токеном } catch (err) { window.location.href = '/login'; }});
// Сервер: проверять токен и уведомлять об истеченииio.use(async (socket, next) => { const decoded = jwt.verify(token, secret); const expiresIn = decoded.exp - Date.now() / 1000;
if (expiresIn < 300) { // Истекает через 5 минут socket.emit('token_expires_soon', { expiresIn }); }
next();});Rate Limiting — защита от спама
Заголовок раздела «Rate Limiting — защита от спама»// В памяти (для одного сервера)const rateLimit = new Map(); // socketId → { count, resetAt }
function checkRateLimit(socketId, limit = 10, windowMs = 1000) { const now = Date.now(); const state = rateLimit.get(socketId) || { count: 0, resetAt: now + windowMs };
if (now > state.resetAt) { state.count = 0; state.resetAt = now + windowMs; }
state.count++; rateLimit.set(socketId, state);
return state.count <= limit;}
io.on('connection', (socket) => { socket.on('send-message', (data, callback) => { // Не более 10 сообщений в секунду if (!checkRateLimit(socket.id, 10, 1000)) { return callback?.({ error: 'Rate limit exceeded' }); } // ... });});
// Redis для нескольких серверовconst { RateLimiterRedis } = require('rate-limiter-flexible');
const rateLimiter = new RateLimiterRedis({ storeClient: redisClient, keyPrefix: 'ws_rl', points: 10, // 10 действий duration: 1, // за 1 секунду blockDuration: 10, // блокировать на 10 секунд после превышения});
io.on('connection', (socket) => { socket.on('send-message', async (data, callback) => { try { await rateLimiter.consume(socket.data.userId); } catch { socket.emit('rate-limited', { retryAfter: 10 }); return callback?.({ error: 'Too many requests' }); } // ... });});Валидация данных
Заголовок раздела «Валидация данных»const { z } = require('zod');
// Схемы для всех событийconst schemas = { 'send-message': z.object({ text: z.string().min(1).max(2000), roomId: z.string().uuid(), }),
'join-room': z.object({ roomId: z.string().uuid(), password: z.string().optional(), }),
'update-status': z.object({ status: z.enum(['online', 'away', 'busy']), }),};
// Middleware для валидацииfunction withValidation(eventName, handler) { return async (data, callback) => { const schema = schemas[eventName]; if (!schema) return handler(data, callback);
const result = schema.safeParse(data); if (!result.success) { return callback?.({ error: 'Validation error', details: result.error.errors }); }
return handler(result.data, callback); };}
io.on('connection', (socket) => { socket.on('send-message', withValidation('send-message', (data, callback) => { // data гарантированно валидна processMessage(data); }));});CORS для Socket.io
Заголовок раздела «CORS для Socket.io»const io = new Server(httpServer, { cors: { origin: (origin, callback) => { const allowedOrigins = [ 'https://myapp.com', 'https://www.myapp.com', process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null, ].filter(Boolean);
if (!origin || allowedOrigins.includes(origin)) { callback(null, true); } else { callback(new Error(`CORS: ${origin} не разрешён`)); } }, methods: ['GET', 'POST'], credentials: true, },});Авторизация на уровне комнат
Заголовок раздела «Авторизация на уровне комнат»// ❌ Плохо: клиент сам указывает к какой комнате присоединитьсяsocket.on('join-room', ({ roomId }) => { socket.join(roomId); // Любой может войти куда угодно!});
// ✅ Хорошо: проверяем доступ на сервереsocket.on('join-room', async ({ roomId }, callback) => { const userId = socket.data.userId;
// Проверить что комната существует и пользователь имеет доступ const room = await db.room.findUnique({ where: { id: roomId }, include: { members: { where: { userId } }, }, });
if (!room) { return callback({ error: 'Комната не найдена' }); }
if (room.type === 'private' && room.members.length === 0) { return callback({ error: 'Нет доступа к этой комнате' }); }
socket.join(roomId); callback({ success: true });});Защита от XSS в чате
Заголовок раздела «Защита от XSS в чате»// Сервер — sanitize входящие данныеconst { JSDOM } = require('jsdom');const createDOMPurify = require('dompurify');const { window } = new JSDOM('');const DOMPurify = createDOMPurify(window);
socket.on('send-message', ({ text }) => { // Очистить HTML теги const sanitized = DOMPurify.sanitize(text, { ALLOWED_TAGS: [], // Только чистый текст ALLOWED_ATTR: [], });
// Или использовать простую функцию const escaped = text .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''');
io.to(roomId).emit('new-message', { text: escaped });});
// Клиент — React автоматически экранирует JSX// Опасно:<div dangerouslySetInnerHTML={{ __html: message.text }} />
// Безопасно:<div>{message.text}</div>Логирование и аудит
Заголовок раздела «Логирование и аудит»// Логировать важные событияio.on('connection', (socket) => { const { userId, username } = socket.data;
logger.info('ws:connect', { socketId: socket.id, userId, username, ip: socket.handshake.address, userAgent: socket.handshake.headers['user-agent'], });
socket.on('disconnect', (reason) => { logger.info('ws:disconnect', { socketId: socket.id, userId, reason }); });
// Логировать подозрительную активность socket.onAny((event, ...args) => { const isKnownEvent = ['send-message', 'join-room', 'typing'].includes(event); if (!isKnownEvent) { logger.warn('ws:unknown-event', { socketId: socket.id, userId, event }); } });});Чеклист безопасности
Заголовок раздела «Чеклист безопасности»Аутентификация: ✅ JWT в auth.token (не в query string!) ✅ Проверка токена в middleware ПЕРЕД любыми событиями ✅ Обновление токена без разрыва соединения ✅ Логаут = disconnect все сокеты пользователя
Авторизация: ✅ Проверять доступ при join-room ✅ Не доверять данным от клиента (userId, role) ✅ Использовать socket.data для хранения авторизованных данных
Валидация: ✅ Zod/Joi для всех входящих событий ✅ Лимит длины сообщений ✅ Санитизация HTML
Rate Limiting: ✅ Лимит событий в секунду ✅ Лимит подключений с одного IP ✅ Блокировка при превышении
HTTPS/WSS: ✅ Только WSS в продакшене (wss://) ✅ CORS только для разрешённых доменов ✅ HSTS заголовки
Мониторинг: ✅ Логировать все подключения/отключения ✅ Алерты на необычную активность ✅ Rate limit метрики в PrometheusЗадания
Заголовок раздела «Задания»- Добавь JWT аутентификацию с middleware к существующему Socket.io серверу
- Реализуй rate limiter: не более 5 сообщений в секунду на пользователя
- Напиши middleware для логирования всех неизвестных событий
- Проведи аудит безопасности: что будет, если не валидировать roomId?
Безопасность real-time = аутентификация (JWT middleware) + авторизация (проверка доступа) + валидация (Zod) + rate limiting + HTTPS. Не доверяй клиенту ничему. Логируй всё важное. Поздравляю — ты прошёл весь курс WebSockets & Real-time!