diff --git a/backend/src/controllers/avatarController.js b/backend/src/controllers/avatarController.js index fc5aa36..268c814 100644 --- a/backend/src/controllers/avatarController.js +++ b/backend/src/controllers/avatarController.js @@ -2,6 +2,7 @@ const path = require('path'); const fs = require('fs'); const db = require('../db/db'); const { audit } = require('../utils/audit'); +const { checkMagicBytes } = require('../utils/magic'); const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars'); @@ -9,6 +10,13 @@ const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars'); function requestAvatar(req, res) { if (!req.file) return res.status(400).json({ error: 'Файл не загружен' }); + // Содержимое должно соответствовать заявленному MIME (client mimetype не доверяем). + const filePath = path.join(AVATARS_DIR, req.file.filename); + if (!checkMagicBytes(filePath, req.file.mimetype)) { + try { fs.unlinkSync(filePath); } catch {} + return res.status(400).json({ error: 'Содержимое файла не является изображением' }); + } + // Cancel any previous pending request from this user (replace it) const prev = db.prepare( "SELECT filename FROM avatar_requests WHERE user_id=? AND status='pending'" diff --git a/backend/src/controllers/classroom/chat.js b/backend/src/controllers/classroom/chat.js index 359d37b..bff47d3 100644 --- a/backend/src/controllers/classroom/chat.js +++ b/backend/src/controllers/classroom/chat.js @@ -4,6 +4,7 @@ const path = require('path'); const fs = require('fs'); const { emitToUser } = require('../../ws-server'); const { emitToSession, hasAccess } = require('./_shared'); +const { checkMagicBytes } = require('../../utils/magic'); const CHAT_UPLOADS_DIR = path.join(__dirname, '../../../uploads/chat'); if (!fs.existsSync(CHAT_UPLOADS_DIR)) fs.mkdirSync(CHAT_UPLOADS_DIR, { recursive: true }); @@ -118,6 +119,12 @@ function reactToMessage(req, res) { function uploadChatAttachment(req, res) { if (!req.file) return res.status(400).json({ error: 'Файл не получен' }); + // Содержимое должно соответствовать заявленному MIME (client mimetype не доверяем). + const filePath = path.join(CHAT_UPLOADS_DIR, req.file.filename); + if (!checkMagicBytes(filePath, req.file.mimetype)) { + try { fs.unlinkSync(filePath); } catch {} + return res.status(400).json({ error: 'Содержимое файла не является изображением' }); + } const url = `/uploads/chat/${req.file.filename}`; const type = req.file.mimetype.startsWith('image/') ? 'image' : 'file'; res.json({ url, type, name: req.file.originalname }); diff --git a/backend/src/controllers/fileController.js b/backend/src/controllers/fileController.js index cb644f0..2113b77 100644 --- a/backend/src/controllers/fileController.js +++ b/backend/src/controllers/fileController.js @@ -298,6 +298,9 @@ function unassignFile(req, res) { function getFolderAccess(req, res) { const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id); if (!fo) return res.status(404).json({ error: 'Folder not found' }); + // Список раздачи (с именами/email учеников) — только владельцу папки или админу. + if (req.user.role !== 'admin' && fo.created_by !== req.user.id) + return res.status(403).json({ error: 'Forbidden' }); const rows = db.prepare(` SELECT fa.id, fa.type, fa.target_id, diff --git a/backend/src/controllers/flashcardController.js b/backend/src/controllers/flashcardController.js index 68f5207..8217576 100644 --- a/backend/src/controllers/flashcardController.js +++ b/backend/src/controllers/flashcardController.js @@ -1,7 +1,12 @@ +const path = require('path'); +const fs = require('fs'); const db = require('../db/db'); const { stripTags } = require('../utils/sanitize'); +const { checkMagicBytes } = require('../utils/magic'); const prepTracks = require('../services/prepTracks'); +const _fcUploadsDir = path.join(__dirname, '../../uploads/flashcards'); + /* ── валидация URL картинки ──────────────────────────────────────────────── Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/) — защита от javascript:/data:/внешних URL в src. Всё прочее → пустая строка. */ @@ -498,6 +503,12 @@ function getRandom(req, res) { back_image. Сам файл уже на диске (multer); БД здесь не трогаем. */ function uploadImage(req, res) { if (!req.file) return res.status(400).json({ error: 'Файл не получен (только изображения до 5 МБ)' }); + // Содержимое должно соответствовать заявленному MIME (client mimetype не доверяем). + const filePath = path.join(_fcUploadsDir, req.file.filename); + if (!checkMagicBytes(filePath, req.file.mimetype)) { + try { fs.unlinkSync(filePath); } catch {} + return res.status(400).json({ error: 'Содержимое файла не является изображением' }); + } res.json({ url: `/uploads/flashcards/${req.file.filename}` }); } diff --git a/backend/src/controllers/testController.js b/backend/src/controllers/testController.js index 868b166..b9bd46c 100644 --- a/backend/src/controllers/testController.js +++ b/backend/src/controllers/testController.js @@ -61,6 +61,18 @@ function getOne(req, res) { `).get(req.params.id); if (!t) return res.status(404).json({ error: 'Not found' }); + // Доступ как в list(): ученик видит только помеченные доступными и не служебные + // экзамен-варианты; учитель — только свои; админ — все. Иначе по id можно было бы + // прочитать тексты заданий из черновиков/вариантов. + const { role, id: uid } = req.user; + const isStudent = role === 'student' || role === 'free_student'; + if (isStudent) { + const isVariant = db.prepare('SELECT 1 FROM exam9_variant_tests WHERE test_id = ?').get(t.id); + if (!t.available_to_students || isVariant) return res.status(404).json({ error: 'Not found' }); + } else if (role !== 'admin' && t.created_by !== uid) { + return res.status(404).json({ error: 'Not found' }); + } + const questions = db.prepare(` SELECT q.id, q.text, q.type, q.difficulty, q.explanation, tp.name AS topic, s.name AS subject_name, diff --git a/backend/src/routes/avatar.js b/backend/src/routes/avatar.js index 6aeefad..adbd478 100644 --- a/backend/src/routes/avatar.js +++ b/backend/src/routes/avatar.js @@ -4,6 +4,7 @@ const multer = require('multer'); const path = require('path'); const crypto = require('crypto'); const { authMiddleware, requireRole } = require('../middleware/auth'); +const { safeExt } = require('../utils/magic'); const ctrl = require('../controllers/avatarController'); /* ── multer: avatars only, 2 MB ────────────────────────────────────────── */ @@ -13,7 +14,9 @@ const AVATAR_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); const storage = multer.diskStorage({ destination: AVATARS_DIR, filename: (_req, file, cb) => { - const ext = path.extname(file.originalname).toLowerCase(); + // Расширение — из проверенного MIME (fileFilter уже сузил до image/*), + // НЕ из client-controlled originalname (иначе .html/.svg → stored-XSS). + const ext = safeExt(file.mimetype, '.png'); const name = crypto.randomBytes(16).toString('hex') + ext; cb(null, name); }, diff --git a/backend/src/routes/classroom.js b/backend/src/routes/classroom.js index 4625ecd..da4f57b 100644 --- a/backend/src/routes/classroom.js +++ b/backend/src/routes/classroom.js @@ -3,6 +3,7 @@ const multer = require('multer'); const path = require('path'); const crypto = require('crypto'); const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth'); +const { safeExt } = require('../utils/magic'); const rateLimit = require('../middleware/rateLimit'); const c = require('../controllers/classroomController'); @@ -11,8 +12,9 @@ const _chatUploadsDir = path.join(__dirname, '../../uploads/chat'); const _chatStorage = multer.diskStorage({ destination: (req, file, cb) => cb(null, _chatUploadsDir), filename: (req, file, cb) => { - const ext = path.extname(file.originalname).toLowerCase().replace(/[^.a-z0-9]/g, ''); - cb(null, crypto.randomBytes(14).toString('hex') + ext); + // Расширение из проверенного MIME, НЕ из originalname (иначе .html/.svg → stored-XSS, + // если каталог chat начнут раздавать статикой). + cb(null, crypto.randomBytes(14).toString('hex') + safeExt(file.mimetype, '.png')); }, }); const chatUpload = multer({ diff --git a/backend/src/routes/flashcards.js b/backend/src/routes/flashcards.js index 0d76fc7..fb8005b 100644 --- a/backend/src/routes/flashcards.js +++ b/backend/src/routes/flashcards.js @@ -7,6 +7,7 @@ const crypto = require('crypto'); const fc = require('../controllers/flashcardController'); const { authMiddleware, requireRole, requirePermission, requirePermissionForStudents } = require('../middleware/auth'); const { requireOwnership } = require('../middleware/ownership'); +const { safeExt } = require('../utils/magic'); /* ── multer для картинок карточек ─────────────────────────────────────── Файлы складываем в backend/uploads/flashcards, отдаём статикой через @@ -18,8 +19,8 @@ if (!fs.existsSync(_fcUploadsDir)) fs.mkdirSync(_fcUploadsDir, { recursive: true const _fcStorage = multer.diskStorage({ destination: (req, file, cb) => cb(null, _fcUploadsDir), filename: (req, file, cb) => { - const ext = path.extname(file.originalname).toLowerCase().replace(/[^.a-z0-9]/g, ''); - cb(null, crypto.randomBytes(14).toString('hex') + (ext || '.png')); + // Расширение из проверенного MIME, НЕ из originalname (иначе .html/.svg → stored-XSS). + cb(null, crypto.randomBytes(14).toString('hex') + safeExt(file.mimetype, '.png')); }, }); const fcUpload = multer({ diff --git a/backend/src/utils/magic.js b/backend/src/utils/magic.js index 76fd099..da0d93d 100644 --- a/backend/src/utils/magic.js +++ b/backend/src/utils/magic.js @@ -18,6 +18,20 @@ const MAGIC = [ { mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', bytes: [0x50,0x4B,0x03,0x04], offset: 0 }, ]; +/* Канонические расширения по проверенному MIME. Имя файла на диске берём + * ОТСЮДА, а не из client-controlled originalname, иначе можно сохранить + * .html/.svg и получить stored-XSS при раздаче статикой. */ +const EXT_FOR_MIME = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'application/pdf': '.pdf', +}; +function safeExt(declaredMime, fallback) { + return EXT_FOR_MIME[declaredMime] || fallback || ''; +} + function checkMagicBytes(filePath, declaredMime) { if (declaredMime === 'text/plain') return true; // txt has no magic bytes const rules = MAGIC.filter(m => m.mime === declaredMime); @@ -35,4 +49,4 @@ function checkMagicBytes(filePath, declaredMime) { } } -module.exports = { checkMagicBytes }; +module.exports = { checkMagicBytes, safeExt, EXT_FOR_MIME }; diff --git a/frontend/classes.html b/frontend/classes.html index 3f01e5f..33b2a81 100644 --- a/frontend/classes.html +++ b/frontend/classes.html @@ -1034,6 +1034,10 @@ function fmtDate(d) { return d ? new Date(d).toLocaleDateString('ru',{day:'numeric',month:'short',year:'numeric'}) : '—'; } function pctCls(p) { return p===null?'':p>=75?'pct-hi':p>=50?'pct-mid':'pct-lo'; } + // Безопасная подстановка строки в JS-строковый литерал внутри inline-обработчика + // (onclick="f('${escJ(name)}')"). esc() не экранирует ' → нужно ещё \-экранировать + // обратный слэш и кавычку, иначе имя ученика с ' даёт XSS. + function escJ(s) { return esc(String(s ?? '').replace(/\\/g,'\\\\').replace(/'/g,"\\'")); } function toast(msg) { const el = document.getElementById('toast'); el.textContent = msg; el.classList.add('show'); @@ -1162,7 +1166,7 @@ ${m.avg_pct!==null?m.avg_pct+'%':'—'} ${fmtDate(m.joined_at)} ${prepToggleHtml(m.id)} - + `; }).join(''); } @@ -1529,7 +1533,7 @@ drop.innerHTML = '
Ничего не найдено
'; } else { drop.innerHTML = matches.map(s => ` -
+
${esc(s.name)}
${esc(s.email)}
`).join(''); @@ -2010,7 +2014,7 @@ const rank = (r, i) => { const medal = ['','',''][i] || (i+1)+'.'; const pc = pctCls(r.percent); - const clickable = r.session_id ? `onclick="openResDrill(${r.session_id},'${esc(r.name)}',${r.percent??'null'})"` : ''; + const clickable = r.session_id ? `onclick="openResDrill(${r.session_id},'${escJ(r.name)}',${r.percent??'null'})"` : ''; return `
${medal}