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 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'"
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/<file>) —
|
||||
защита от 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}` });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user