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:
@@ -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); }
|
||||||
|
|||||||
@@ -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 (?,?,?,?)`)
|
||||||
|
|||||||
@@ -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)' });
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 -->
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user