fix: полное ревью системы — 15 исправлений безопасности и надёжности

Безопасность:
- tests/🆔 скрыть is_correct и explanation для студентов (P0)
- SQL injection: limit/offset через placeholder вместо template literal
- Stored XSS: stripTags для lesson comments, flashcards, redBook sightings
- profile.html: escape e.message в showMsg (XSS через server error)
- attachment_url: валидация только /uploads/* путей
- requestId: генерировать UUID сервером, не доверять клиенту
- register: скрыть token_version из ответа

Надёжность:
- register: обработка UNIQUE constraint race condition
- pet buyBg: re-check баланса внутри транзакции
- DB errors: скрыть e.message в testController/questionController/courseController
- preferences: лимит 50KB на размер JSON

UX:
- board.html: debounce 250ms на search input

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-16 10:59:19 +03:00
parent 6cd0cf34d4
commit 3a4623a60a
12 changed files with 55 additions and 19 deletions
+10 -4
View File
@@ -27,11 +27,17 @@ async function register(req, res, next) {
const cleanName = stripTags(name.trim()); const cleanName = stripTags(name.trim());
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS); const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const { lastInsertRowid } = db.prepare( let lastInsertRowid;
'INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)' try {
).run(email, hash, cleanName); ({ lastInsertRowid } = db.prepare(
'INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)'
).run(email, hash, cleanName));
} catch (e) {
if (e.message?.includes('UNIQUE')) return res.status(409).json({ error: 'Email already registered' });
throw e;
}
const user = db.prepare('SELECT id, email, name, role, token_version FROM users WHERE id = ?').get(lastInsertRowid); const user = db.prepare('SELECT id, email, name, role FROM users WHERE id = ?').get(lastInsertRowid);
const token = signToken(user); const token = signToken(user);
res.status(201).json({ token, user }); res.status(201).json({ token, user });
} catch (err) { next(err); } } catch (err) { next(err); }
+1 -1
View File
@@ -474,7 +474,7 @@ function assignCourseToClass(req, res) {
ON CONFLICT (class_id, course_id) DO UPDATE SET deadline=excluded.deadline ON CONFLICT (class_id, course_id) DO UPDATE SET deadline=excluded.deadline
`).run(classId, courseId, deadline || null, req.user.id); `).run(classId, courseId, deadline || null, req.user.id);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { res.status(400).json({ error: e.message }); } } catch (e) { console.error('[course assign]', e.message); res.status(400).json({ error: 'Ошибка назначения курса' }); }
} }
function unassignCourseFromClass(req, res) { function unassignCourseFromClass(req, res) {
@@ -1,4 +1,5 @@
const db = require('../db/db'); const db = require('../db/db');
const { stripTags } = require('../utils/sanitize');
/* ── SM-2 algorithm ─────────────────────────────────────────────────────── /* ── SM-2 algorithm ───────────────────────────────────────────────────────
quality: 0 = blackout, 1 = wrong, 2 = wrong but familiar, quality: 0 = blackout, 1 = wrong, 2 = wrong but familiar,
@@ -94,7 +95,8 @@ function addCard(req, res) {
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`) const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid); .get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' }); if (!deck) return res.status(404).json({ error: 'Not found' });
const { front = '', back = '' } = req.body; const front = stripTags((req.body.front || '').slice(0, 5000));
const back = stripTags((req.body.back || '').slice(0, 5000));
const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`) const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`)
.get(deck.id)?.m ?? -1; .get(deck.id)?.m ?? -1;
const r = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`) const r = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`)
+2 -1
View File
@@ -1,5 +1,6 @@
const db = require('../db/db'); const db = require('../db/db');
const { onLessonComplete } = require('./gamificationController'); const { onLessonComplete } = require('./gamificationController');
const { stripTags } = require('../utils/sanitize');
/* ── helpers ──────────────────────────────────────────────────────────── */ /* ── helpers ──────────────────────────────────────────────────────────── */
function parseBlock(b) { function parseBlock(b) {
@@ -249,7 +250,7 @@ function addComment(req, res) {
const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(req.params.id); const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' }); if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
const text = (req.body.text || '').trim(); const text = stripTags((req.body.text || '').trim());
if (!text) return res.status(400).json({ error: 'text required' }); if (!text) return res.status(400).json({ error: 'text required' });
if (text.length > 2000) return res.status(400).json({ error: 'Comment too long (max 2000)' }); if (text.length > 2000) return res.status(400).json({ error: 'Comment too long (max 2000)' });
+11 -2
View File
@@ -245,8 +245,17 @@ function buyBg(req, res) {
const owned = _parseOwned(user.pet_bg_owned); const owned = _parseOwned(user.pet_bg_owned);
if (!owned.includes(id)) { if (!owned.includes(id)) {
if ((user.coins || 0) < item.price) return res.status(400).json({ error: 'insufficient_coins' }); if ((user.coins || 0) < item.price) return res.status(400).json({ error: 'insufficient_coins' });
db.prepare('UPDATE users SET coins=coins-?, pet_bg_owned=?, pet_bg=? WHERE id=?') const buy = db.transaction(() => {
.run(item.price, JSON.stringify([...owned, id]), id, req.user.id); // Re-check balance inside transaction to prevent race
const fresh = db.prepare('SELECT coins FROM users WHERE id=?').get(req.user.id);
if ((fresh.coins || 0) < item.price) throw new Error('insufficient_coins');
db.prepare('UPDATE users SET coins=coins-?, pet_bg_owned=?, pet_bg=? WHERE id=?')
.run(item.price, JSON.stringify([...owned, id]), id, req.user.id);
});
try { buy(); } catch (e) {
if (e.message === 'insufficient_coins') return res.status(400).json({ error: 'insufficient_coins' });
throw e;
}
} else { } else {
db.prepare('UPDATE users SET pet_bg=? WHERE id=?').run(id, req.user.id); db.prepare('UPDATE users SET pet_bg=? WHERE id=?').run(id, req.user.id);
} }
@@ -35,6 +35,8 @@ function patchPreferences(req, res) {
db.prepare('SELECT data FROM user_preferences WHERE user_id = ?').get(req.user.id)?.data || '{}' db.prepare('SELECT data FROM user_preferences WHERE user_id = ?').get(req.user.id)?.data || '{}'
); );
const merged = deepMerge(current, req.body); const merged = deepMerge(current, req.body);
if (JSON.stringify(merged).length > 50_000)
return res.status(413).json({ error: 'Preferences too large (max 50KB)' });
db.prepare(` db.prepare(`
INSERT INTO user_preferences (user_id, data, updated_at) INSERT INTO user_preferences (user_id, data, updated_at)
VALUES (?, ?, datetime('now')) VALUES (?, ?, datetime('now'))
@@ -165,7 +165,8 @@ function update(req, res) {
})(); })();
res.json({ id: Number(qid) }); res.json({ id: Number(qid) });
} catch (err) { } catch (err) {
res.status(err.status || 500).json({ error: err.message }); console.error('[question update]', err.message);
res.status(err.status || 500).json({ error: err.status ? err.message : 'Ошибка обновления' });
} }
} }
@@ -239,7 +240,8 @@ function importCSV(req, res) {
} }
})(); })();
} catch (e) { } catch (e) {
return res.status(500).json({ error: e.message }); console.error('[question import]', e.message);
return res.status(500).json({ error: 'Ошибка импорта' });
} }
res.json({ imported, errors }); res.json({ imported, errors });
+5 -2
View File
@@ -1,5 +1,6 @@
const db = require('../db/db'); const db = require('../db/db');
const { awardXP, checkRedBookAchievements } = require('./gamificationController'); const { awardXP, checkRedBookAchievements } = require('./gamificationController');
const { stripTags } = require('../utils/sanitize');
/* ── helpers ─────────────────────────────────────────────────────────── */ /* ── helpers ─────────────────────────────────────────────────────────── */
const stmts = { const stmts = {
@@ -86,7 +87,8 @@ function buildSpeciesList(filter = {}) {
sql += ' ORDER BY s.category, s.name_ru'; sql += ' ORDER BY s.category, s.name_ru';
const limit = Math.min(parseInt(filter.limit) || 50, 100); const limit = Math.min(parseInt(filter.limit) || 50, 100);
const offset = parseInt(filter.offset) || 0; const offset = parseInt(filter.offset) || 0;
sql += ` LIMIT ${limit} OFFSET ${offset}`; sql += ' LIMIT ? OFFSET ?';
params.push(limit, offset);
return db.prepare(sql).all(...params); return db.prepare(sql).all(...params);
} }
@@ -303,7 +305,8 @@ exports.getSightings = (req, res) => {
exports.addSighting = (req, res) => { exports.addSighting = (req, res) => {
const { species_id, region_code, description } = req.body || {}; const { species_id, region_code, description } = req.body || {};
if (!species_id) return res.status(400).json({ error: 'species_id обязателен' }); if (!species_id) return res.status(400).json({ error: 'species_id обязателен' });
const id = stmts.addSighting.run(req.user.id, species_id, region_code || '', description || '').lastInsertRowid; const safeDesc = stripTags((description || '').slice(0, 2000));
const id = stmts.addSighting.run(req.user.id, species_id, region_code || '', safeDesc).lastInsertRowid;
try { checkRedBookAchievements(req.user.id); } catch {} try { checkRedBookAchievements(req.user.id); } catch {}
res.json({ id }); res.json({ id });
}; };
+13 -2
View File
@@ -59,6 +59,15 @@ function getOne(req, res) {
options_json: undefined, options_json: undefined,
})); }));
// Hide is_correct from students — only teachers/admins see correct answers
const isPrivileged = req.user.role === 'teacher' || req.user.role === 'admin';
if (!isPrivileged) {
questions.forEach(q => {
q.options.forEach(o => { delete o.is_correct; });
delete q.explanation;
});
}
res.json({ ...t, questions }); res.json({ ...t, questions });
} }
@@ -99,7 +108,8 @@ function addQuestions(req, res) {
try { try {
db.transaction(() => { question_ids.forEach(qid => ins.run(testId, qid, idx++)); })(); db.transaction(() => { question_ids.forEach(qid => ins.run(testId, qid, idx++)); })();
} catch (e) { } catch (e) {
return res.status(500).json({ error: e.message }); console.error('[testController] addQuestions error:', e.message);
return res.status(500).json({ error: 'Ошибка добавления вопросов' });
} }
res.json({ ok: true }); res.json({ ok: true });
} }
@@ -126,7 +136,8 @@ function reorderQuestions(req, res) {
ids.forEach((qid, i) => upd.run(i, testId, qid)); ids.forEach((qid, i) => upd.run(i, testId, qid));
})(); })();
} catch (e) { } catch (e) {
return res.status(500).json({ error: e.message }); console.error('[testController] reorderQuestions error:', e.message);
return res.status(500).json({ error: 'Ошибка сортировки' });
} }
res.json({ ok: true }); res.json({ ok: true });
} }
+1 -1
View File
@@ -25,7 +25,7 @@ function getErrorLogStmt() {
* Honour an incoming X-Request-Id from trusted proxies/gateways when present. * Honour an incoming X-Request-Id from trusted proxies/gateways when present.
*/ */
function requestId(req, res, next) { function requestId(req, res, next) {
const id = req.headers['x-request-id'] || crypto.randomUUID(); const id = crypto.randomUUID();
req.requestId = id; req.requestId = id;
res.setHeader('X-Request-Id', id); res.setHeader('X-Request-Id', id);
next(); next();
+1 -1
View File
@@ -291,7 +291,7 @@
<!-- search --> <!-- search -->
<div class="search-wrap"> <div class="search-wrap">
<i data-lucide="search" class="sb-icon" style="width:16px;height:16px"></i> <i data-lucide="search" class="sb-icon" style="width:16px;height:16px"></i>
<input class="search-input" type="text" id="search-input" placeholder="Поиск по доске…" oninput="render()" /> <input class="search-input" type="text" id="search-input" placeholder="Поиск по доске…" oninput="clearTimeout(window._boardSearchTimer); window._boardSearchTimer = setTimeout(render, 250)" />
</div> </div>
<!-- filter tabs --> <!-- filter tabs -->
+2 -2
View File
@@ -1376,7 +1376,7 @@
const ini = name.split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS'; const ini = name.split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
document.getElementById('big-avatar').textContent = ini; document.getElementById('big-avatar').textContent = ini;
document.getElementById('nav-avatar').textContent = ini; document.getElementById('nav-avatar').textContent = ini;
} catch(e) { showMsg(msg, e.message||'Ошибка','err'); } } catch(e) { showMsg(msg, LS.esc(e.message||'Ошибка'),'err'); }
finally { btn.disabled = false; } finally { btn.disabled = false; }
} }
@@ -1395,7 +1395,7 @@
await LS.updateProfile({ currentPassword:cur, newPassword:nw }); await LS.updateProfile({ currentPassword:cur, newPassword:nw });
showMsg(msg,'Пароль изменён <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>','ok'); showMsg(msg,'Пароль изменён <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>','ok');
['inp-cur-pwd','inp-new-pwd','inp-conf-pwd'].forEach(id => document.getElementById(id).value=''); ['inp-cur-pwd','inp-new-pwd','inp-conf-pwd'].forEach(id => document.getElementById(id).value='');
} catch(e) { showMsg(msg, e.message||'Ошибка','err'); } } catch(e) { showMsg(msg, LS.esc(e.message||'Ошибка'),'err'); }
finally { btn.disabled = false; } finally { btn.disabled = false; }
} }