From 646e93cf46e47153fac2b76a059062cb8aa6eb9d Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 12 Jun 2026 22:00:23 +0300 Subject: [PATCH] =?UTF-8?q?fix(security):=20=D0=BF=D0=B5=D1=80-=D1=8E?= =?UTF-8?q?=D0=B7=D0=B5=D1=80=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=D1=8B=20?= =?UTF-8?q?=D0=98=D0=98=20+=20SSE=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=BE=D1=80=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=82=D0=B8=D0=BA=D0=B5=D1=82=20(=D0=A1=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D1=821=20#5,#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5 rate-limit (byUser) на дорогих LLM-эндпоинтах: /assistant/ask (20/мин), /assistant/flashcards (10/мин), /imggen (20/мин) — поверх cooldown/дневного лимита. Защита от «сжигания» бюджета провайдера одним аккаунтом. #6 SSE больше не таскает JWT в URL: добавлен authed /notifications/stream-ticket (одноразовый тикет, TTL 30с), клиент берёт тикет заголовком и подключается с ?ticket=. ?token= оставлен как временный фоллбэк для старых клиентов. Co-Authored-By: Claude Opus 4.8 --- .../src/controllers/notificationController.js | 54 ++++++++++++++----- backend/src/routes/assistant.js | 9 +++- backend/src/routes/imggen.js | 6 ++- backend/src/routes/notifications.js | 1 + js/api.js | 26 ++++++--- 5 files changed, 74 insertions(+), 22 deletions(-) diff --git a/backend/src/controllers/notificationController.js b/backend/src/controllers/notificationController.js index 91e363e..7ee42d0 100644 --- a/backend/src/controllers/notificationController.js +++ b/backend/src/controllers/notificationController.js @@ -1,7 +1,28 @@ -const jwt = require('jsonwebtoken'); +const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); const db = require('../db/db'); const { addClient, removeClient } = require('../sse'); +/* Одноразовые тикеты для SSE: EventSource не умеет слать заголовки, поэтому раньше + JWT шёл в ?token= (утекал в логи/Referer). Теперь клиент берёт короткоживущий + одноразовый тикет authed-запросом и подключается с ?ticket=. */ +const _tickets = new Map(); // ticket -> { userId, exp } +const TICKET_TTL = 30_000; +function _consumeTicket(t) { + const e = _tickets.get(t); + if (!e) return null; + _tickets.delete(t); // одноразовый + return Date.now() > e.exp ? null : e.userId; +} +setInterval(() => { const now = Date.now(); for (const [t, e] of _tickets) if (now > e.exp) _tickets.delete(t); }, 60_000).unref(); + +/* ── GET /api/notifications/stream-ticket ── (authed) выдаёт тикет для SSE ── */ +function streamTicket(req, res) { + const t = crypto.randomBytes(18).toString('base64url'); + _tickets.set(t, { userId: req.user.id, exp: Date.now() + TICKET_TTL }); + res.json({ ticket: t }); +} + const _stmts = { list: db.prepare('SELECT id, type, message, link, is_read, created_at FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50'), markOne: db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?'), @@ -30,19 +51,28 @@ function markAllRead(req, res) { /* ── GET /api/notifications/stream ── SSE (auth via ?token=JWT) ─────────── */ function stream(req, res) { - const token = req.query.token; - if (!token) return res.status(401).end(); - let userId; - try { - const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); - const fresh = _stmts.getUser.get(payload.id); + if (req.query.ticket) { + // Предпочтительный путь: одноразовый тикет (токен не светится в URL). + userId = _consumeTicket(req.query.ticket); + if (!userId) return res.status(401).end(); + const fresh = _stmts.getUser.get(userId); if (!fresh) return res.status(401).end(); if (fresh.is_banned) return res.status(403).end(); - if (fresh.token_version != null && payload.tv !== fresh.token_version) return res.status(401).end(); - userId = payload.id; - } catch { - return res.status(401).end(); + } else { + // Обратная совместимость: ?token=JWT (помечен на удаление после выката клиента). + const token = req.query.token; + if (!token) return res.status(401).end(); + try { + const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); + const fresh = _stmts.getUser.get(payload.id); + if (!fresh) return res.status(401).end(); + if (fresh.is_banned) return res.status(403).end(); + if (fresh.token_version != null && payload.tv !== fresh.token_version) return res.status(401).end(); + userId = payload.id; + } catch { + return res.status(401).end(); + } } res.setHeader('Content-Type', 'text/event-stream'); @@ -63,4 +93,4 @@ function stream(req, res) { }); } -module.exports = { list, markRead, markAllRead, stream }; +module.exports = { list, markRead, markAllRead, stream, streamTicket }; diff --git a/backend/src/routes/assistant.js b/backend/src/routes/assistant.js index c168a99..acf6c69 100644 --- a/backend/src/routes/assistant.js +++ b/backend/src/routes/assistant.js @@ -3,16 +3,21 @@ * 'pet' навешивается при монтировании в server.js. */ const router = require('express').Router(); const { authMiddleware, requireRole } = require('../middleware/auth'); +const rateLimit = require('../middleware/rateLimit'); const ctrl = require('../controllers/assistantController'); router.use(authMiddleware); +// Дорогие LLM-вызовы — пер-юзер лимит (защита от «сжигания» бюджета провайдера). +const askLimiter = rateLimit({ windowMs: 60_000, max: 20, byUser: true, message: 'Слишком много запросов к ИИ — подожди минутку' }); +const fcLimiter = rateLimit({ windowMs: 60_000, max: 10, byUser: true, message: 'Слишком часто — подожди минутку' }); + router.get('/context', ctrl.getContext); router.post('/seen', ctrl.markSeen); router.post('/dismiss', ctrl.dismiss); router.patch('/settings', ctrl.setSettings); -router.post('/ask', ctrl.ask); -router.post('/flashcards', ctrl.flashcardsFromText); +router.post('/ask', askLimiter, ctrl.ask); +router.post('/flashcards', fcLimiter, ctrl.flashcardsFromText); router.post('/feedback', ctrl.feedback); router.get('/memory', ctrl.getMemory); router.delete('/memory', ctrl.clearMemory); diff --git a/backend/src/routes/imggen.js b/backend/src/routes/imggen.js index 87a59e8..7f675f0 100644 --- a/backend/src/routes/imggen.js +++ b/backend/src/routes/imggen.js @@ -1,10 +1,14 @@ 'use strict'; const router = require('express').Router(); const { authMiddleware } = require('../middleware/auth'); +const rateLimit = require('../middleware/rateLimit'); const ctrl = require('../controllers/imggenController'); +// Пер-юзер лимит поверх cooldown/дневного лимита в контроллере (защита от абуза). +const genLimiter = rateLimit({ windowMs: 60_000, max: 20, byUser: true, message: 'Слишком много генераций — подожди минутку' }); + router.use(authMiddleware); router.get('/status', ctrl.status); -router.post('/', ctrl.generate); +router.post('/', genLimiter, ctrl.generate); module.exports = router; diff --git a/backend/src/routes/notifications.js b/backend/src/routes/notifications.js index 7ef258a..1b51b10 100644 --- a/backend/src/routes/notifications.js +++ b/backend/src/routes/notifications.js @@ -6,6 +6,7 @@ const ctrl = require('../controllers/notificationController'); router.get('/stream', ctrl.stream); router.use(authMiddleware); +router.get('/stream-ticket', ctrl.streamTicket); router.get('/', ctrl.list); router.post('/read-all', ctrl.markAllRead); router.patch('/:id/read', ctrl.markRead); diff --git a/js/api.js b/js/api.js index f3aaee1..373340b 100644 --- a/js/api.js +++ b/js/api.js @@ -1386,14 +1386,28 @@ async function markAllNotifsRead() { return req('POST', '/notifications/read-al /* ── SSE real-time notifications ─────────────────────────────────────────── */ /* ── Shared SSE singleton — all listeners share one EventSource ─────── */ let _sseShared = null; +let _sseConnecting = false; let _sseRetryMs = 2000; let _sseEverConnected = false; // tracks whether SSE has successfully opened before const _sseListeners = new Set(); -function _sseConnect() { - const token = getToken(); - if (!token) return; - const url = `${API}/notifications/stream?token=${encodeURIComponent(token)}`; +function _sseRetry() { + const delay = Math.min(_sseRetryMs, 30000); + _sseRetryMs = Math.min(_sseRetryMs * 2, 30000); + setTimeout(_sseConnect, delay); +} + +async function _sseConnect() { + if (_sseShared || _sseConnecting) return; + if (!getToken()) return; + // Берём одноразовый тикет authed-запросом (Bearer в заголовке) — токен не попадает в URL. + _sseConnecting = true; + let ticket = null; + try { const r = await req('GET', '/notifications/stream-ticket'); ticket = r && r.ticket; } catch (e) {} + _sseConnecting = false; + if (_sseShared) return; // подключились параллельно + if (!ticket) { _sseRetry(); return; } // не вышло — повтор по backoff + const url = `${API}/notifications/stream?ticket=${encodeURIComponent(ticket)}`; const es = new EventSource(url); _sseShared = es; es.onopen = () => { @@ -1412,9 +1426,7 @@ function _sseConnect() { es.onerror = () => { es.close(); _sseShared = null; - const delay = Math.min(_sseRetryMs, 30000); - _sseRetryMs = Math.min(_sseRetryMs * 2, 30000); - setTimeout(_sseConnect, delay); + _sseRetry(); }; }