fix(security): харднинг загрузки файлов, контроль доступа и XSS
Подхвачено из закрытой параллельной сессии (план project_hardening_2026). Загрузки: magic.js получает safeExt/EXT_FOR_MIME — имя файла на диске берёт расширение из проверенного MIME, а не из client originalname (анти stored-XSS .html/.svg). avatar/flashcard/chat-загрузки дополнительно проверяют magic-байты: содержимое должно соответствовать MIME, иначе файл удаляется и 400. Доступ: fileController.getFolderAccess отдаёт список раздачи только владельцу или админу (была утечка имён/email учеников). testController.getOne гейтит видимость как list() — ученик не прочитает тексты заданий черновиков/вариантов по id. XSS: classes.html escJ() экранирует строку для JS-литерала в inline-onclick (имя ученика с кавычкой больше не ломает обработчик). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const { audit } = require('../utils/audit');
|
const { audit } = require('../utils/audit');
|
||||||
|
const { checkMagicBytes } = require('../utils/magic');
|
||||||
|
|
||||||
const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars');
|
const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars');
|
||||||
|
|
||||||
@@ -9,6 +10,13 @@ const AVATARS_DIR = path.join(__dirname, '../../uploads/avatars');
|
|||||||
function requestAvatar(req, res) {
|
function requestAvatar(req, res) {
|
||||||
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
|
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)
|
// Cancel any previous pending request from this user (replace it)
|
||||||
const prev = db.prepare(
|
const prev = db.prepare(
|
||||||
"SELECT filename FROM avatar_requests WHERE user_id=? AND status='pending'"
|
"SELECT filename FROM avatar_requests WHERE user_id=? AND status='pending'"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { emitToUser } = require('../../ws-server');
|
const { emitToUser } = require('../../ws-server');
|
||||||
const { emitToSession, hasAccess } = require('./_shared');
|
const { emitToSession, hasAccess } = require('./_shared');
|
||||||
|
const { checkMagicBytes } = require('../../utils/magic');
|
||||||
|
|
||||||
const CHAT_UPLOADS_DIR = path.join(__dirname, '../../../uploads/chat');
|
const CHAT_UPLOADS_DIR = path.join(__dirname, '../../../uploads/chat');
|
||||||
if (!fs.existsSync(CHAT_UPLOADS_DIR)) fs.mkdirSync(CHAT_UPLOADS_DIR, { recursive: true });
|
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) {
|
function uploadChatAttachment(req, res) {
|
||||||
if (!req.file) return res.status(400).json({ error: 'Файл не получен' });
|
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 url = `/uploads/chat/${req.file.filename}`;
|
||||||
const type = req.file.mimetype.startsWith('image/') ? 'image' : 'file';
|
const type = req.file.mimetype.startsWith('image/') ? 'image' : 'file';
|
||||||
res.json({ url, type, name: req.file.originalname });
|
res.json({ url, type, name: req.file.originalname });
|
||||||
|
|||||||
@@ -298,6 +298,9 @@ function unassignFile(req, res) {
|
|||||||
function getFolderAccess(req, res) {
|
function getFolderAccess(req, res) {
|
||||||
const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id);
|
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' });
|
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(`
|
const rows = db.prepare(`
|
||||||
SELECT fa.id, fa.type, fa.target_id,
|
SELECT fa.id, fa.type, fa.target_id,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const { stripTags } = require('../utils/sanitize');
|
const { stripTags } = require('../utils/sanitize');
|
||||||
|
const { checkMagicBytes } = require('../utils/magic');
|
||||||
const prepTracks = require('../services/prepTracks');
|
const prepTracks = require('../services/prepTracks');
|
||||||
|
|
||||||
|
const _fcUploadsDir = path.join(__dirname, '../../uploads/flashcards');
|
||||||
|
|
||||||
/* ── валидация URL картинки ────────────────────────────────────────────────
|
/* ── валидация URL картинки ────────────────────────────────────────────────
|
||||||
Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/<file>) —
|
Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/<file>) —
|
||||||
защита от javascript:/data:/внешних URL в src. Всё прочее → пустая строка. */
|
защита от javascript:/data:/внешних URL в src. Всё прочее → пустая строка. */
|
||||||
@@ -498,6 +503,12 @@ function getRandom(req, res) {
|
|||||||
back_image. Сам файл уже на диске (multer); БД здесь не трогаем. */
|
back_image. Сам файл уже на диске (multer); БД здесь не трогаем. */
|
||||||
function uploadImage(req, res) {
|
function uploadImage(req, res) {
|
||||||
if (!req.file) return res.status(400).json({ error: 'Файл не получен (только изображения до 5 МБ)' });
|
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}` });
|
res.json({ url: `/uploads/flashcards/${req.file.filename}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,18 @@ function getOne(req, res) {
|
|||||||
`).get(req.params.id);
|
`).get(req.params.id);
|
||||||
if (!t) return res.status(404).json({ error: 'Not found' });
|
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(`
|
const questions = db.prepare(`
|
||||||
SELECT q.id, q.text, q.type, q.difficulty, q.explanation,
|
SELECT q.id, q.text, q.type, q.difficulty, q.explanation,
|
||||||
tp.name AS topic, s.name AS subject_name,
|
tp.name AS topic, s.name AS subject_name,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const multer = require('multer');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||||
|
const { safeExt } = require('../utils/magic');
|
||||||
const ctrl = require('../controllers/avatarController');
|
const ctrl = require('../controllers/avatarController');
|
||||||
|
|
||||||
/* ── multer: avatars only, 2 MB ────────────────────────────────────────── */
|
/* ── multer: avatars only, 2 MB ────────────────────────────────────────── */
|
||||||
@@ -13,7 +14,9 @@ const AVATAR_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
|||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: AVATARS_DIR,
|
destination: AVATARS_DIR,
|
||||||
filename: (_req, file, cb) => {
|
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;
|
const name = crypto.randomBytes(16).toString('hex') + ext;
|
||||||
cb(null, name);
|
cb(null, name);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const multer = require('multer');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
|
const { authMiddleware, requireRole, requirePermission } = require('../middleware/auth');
|
||||||
|
const { safeExt } = require('../utils/magic');
|
||||||
const rateLimit = require('../middleware/rateLimit');
|
const rateLimit = require('../middleware/rateLimit');
|
||||||
const c = require('../controllers/classroomController');
|
const c = require('../controllers/classroomController');
|
||||||
|
|
||||||
@@ -11,8 +12,9 @@ const _chatUploadsDir = path.join(__dirname, '../../uploads/chat');
|
|||||||
const _chatStorage = multer.diskStorage({
|
const _chatStorage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => cb(null, _chatUploadsDir),
|
destination: (req, file, cb) => cb(null, _chatUploadsDir),
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const ext = path.extname(file.originalname).toLowerCase().replace(/[^.a-z0-9]/g, '');
|
// Расширение из проверенного MIME, НЕ из originalname (иначе .html/.svg → stored-XSS,
|
||||||
cb(null, crypto.randomBytes(14).toString('hex') + ext);
|
// если каталог chat начнут раздавать статикой).
|
||||||
|
cb(null, crypto.randomBytes(14).toString('hex') + safeExt(file.mimetype, '.png'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const chatUpload = multer({
|
const chatUpload = multer({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const crypto = require('crypto');
|
|||||||
const fc = require('../controllers/flashcardController');
|
const fc = require('../controllers/flashcardController');
|
||||||
const { authMiddleware, requireRole, requirePermission, requirePermissionForStudents } = require('../middleware/auth');
|
const { authMiddleware, requireRole, requirePermission, requirePermissionForStudents } = require('../middleware/auth');
|
||||||
const { requireOwnership } = require('../middleware/ownership');
|
const { requireOwnership } = require('../middleware/ownership');
|
||||||
|
const { safeExt } = require('../utils/magic');
|
||||||
|
|
||||||
/* ── multer для картинок карточек ───────────────────────────────────────
|
/* ── multer для картинок карточек ───────────────────────────────────────
|
||||||
Файлы складываем в backend/uploads/flashcards, отдаём статикой через
|
Файлы складываем в backend/uploads/flashcards, отдаём статикой через
|
||||||
@@ -18,8 +19,8 @@ if (!fs.existsSync(_fcUploadsDir)) fs.mkdirSync(_fcUploadsDir, { recursive: true
|
|||||||
const _fcStorage = multer.diskStorage({
|
const _fcStorage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => cb(null, _fcUploadsDir),
|
destination: (req, file, cb) => cb(null, _fcUploadsDir),
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const ext = path.extname(file.originalname).toLowerCase().replace(/[^.a-z0-9]/g, '');
|
// Расширение из проверенного MIME, НЕ из originalname (иначе .html/.svg → stored-XSS).
|
||||||
cb(null, crypto.randomBytes(14).toString('hex') + (ext || '.png'));
|
cb(null, crypto.randomBytes(14).toString('hex') + safeExt(file.mimetype, '.png'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const fcUpload = multer({
|
const fcUpload = multer({
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ const MAGIC = [
|
|||||||
{ mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', bytes: [0x50,0x4B,0x03,0x04], offset: 0 },
|
{ 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) {
|
function checkMagicBytes(filePath, declaredMime) {
|
||||||
if (declaredMime === 'text/plain') return true; // txt has no magic bytes
|
if (declaredMime === 'text/plain') return true; // txt has no magic bytes
|
||||||
const rules = MAGIC.filter(m => m.mime === declaredMime);
|
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 };
|
||||||
|
|||||||
@@ -1034,6 +1034,10 @@
|
|||||||
|
|
||||||
function fmtDate(d) { return d ? new Date(d).toLocaleDateString('ru',{day:'numeric',month:'short',year:'numeric'}) : '—'; }
|
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'; }
|
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) {
|
function toast(msg) {
|
||||||
const el = document.getElementById('toast');
|
const el = document.getElementById('toast');
|
||||||
el.textContent = msg; el.classList.add('show');
|
el.textContent = msg; el.classList.add('show');
|
||||||
@@ -1162,7 +1166,7 @@
|
|||||||
<td><span class="pct-cell ${pc}">${m.avg_pct!==null?m.avg_pct+'%':'—'}</span></td>
|
<td><span class="pct-cell ${pc}">${m.avg_pct!==null?m.avg_pct+'%':'—'}</span></td>
|
||||||
<td style="color:var(--text-3);font-size:0.78rem">${fmtDate(m.joined_at)}</td>
|
<td style="color:var(--text-3);font-size:0.78rem">${fmtDate(m.joined_at)}</td>
|
||||||
<td>${prepToggleHtml(m.id)}</td>
|
<td>${prepToggleHtml(m.id)}</td>
|
||||||
<td><button class="btn-danger" onclick="kickMember(${m.id},'${esc(m.name)}')">Удалить</button></td>
|
<td><button class="btn-danger" onclick="kickMember(${m.id},'${escJ(m.name)}')">Удалить</button></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -1529,7 +1533,7 @@
|
|||||||
drop.innerHTML = '<div class="student-opt-empty">Ничего не найдено</div>';
|
drop.innerHTML = '<div class="student-opt-empty">Ничего не найдено</div>';
|
||||||
} else {
|
} else {
|
||||||
drop.innerHTML = matches.map(s => `
|
drop.innerHTML = matches.map(s => `
|
||||||
<div class="student-opt" onmousedown="selectStudent(${s.id},'${esc(s.name)}','${esc(s.email)}')">
|
<div class="student-opt" onmousedown="selectStudent(${s.id},'${escJ(s.name)}','${escJ(s.email)}')">
|
||||||
<div class="student-opt-name">${esc(s.name)}</div>
|
<div class="student-opt-name">${esc(s.name)}</div>
|
||||||
<div class="student-opt-email">${esc(s.email)}</div>
|
<div class="student-opt-email">${esc(s.email)}</div>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
@@ -2010,7 +2014,7 @@
|
|||||||
const rank = (r, i) => {
|
const rank = (r, i) => {
|
||||||
const medal = ['<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>'][i] || (i+1)+'.';
|
const medal = ['<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>','<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/></svg>'][i] || (i+1)+'.';
|
||||||
const pc = pctCls(r.percent);
|
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 `<div class="res-row" ${clickable} title="${r.session_id ? 'Посмотреть ответы' : ''}">
|
return `<div class="res-row" ${clickable} title="${r.session_id ? 'Посмотреть ответы' : ''}">
|
||||||
<div class="res-rank">${medal}</div>
|
<div class="res-rank">${medal}</div>
|
||||||
<div class="res-name">
|
<div class="res-name">
|
||||||
|
|||||||
Reference in New Issue
Block a user