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 hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
const { lastInsertRowid } = db.prepare(
|
||||
'INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)'
|
||||
).run(email, hash, cleanName);
|
||||
let lastInsertRowid;
|
||||
try {
|
||||
({ 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);
|
||||
res.status(201).json({ token, user });
|
||||
} catch (err) { next(err); }
|
||||
|
||||
@@ -474,7 +474,7 @@ function assignCourseToClass(req, res) {
|
||||
ON CONFLICT (class_id, course_id) DO UPDATE SET deadline=excluded.deadline
|
||||
`).run(classId, courseId, deadline || null, req.user.id);
|
||||
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) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const db = require('../db/db');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
|
||||
/* ── SM-2 algorithm ───────────────────────────────────────────────────────
|
||||
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 = ?`)
|
||||
.get(req.params.id, uid);
|
||||
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 = ?`)
|
||||
.get(deck.id)?.m ?? -1;
|
||||
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 { onLessonComplete } = require('./gamificationController');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
|
||||
/* ── helpers ──────────────────────────────────────────────────────────── */
|
||||
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);
|
||||
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.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);
|
||||
if (!owned.includes(id)) {
|
||||
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=?')
|
||||
.run(item.price, JSON.stringify([...owned, id]), id, req.user.id);
|
||||
const buy = db.transaction(() => {
|
||||
// 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 {
|
||||
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 || '{}'
|
||||
);
|
||||
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(`
|
||||
INSERT INTO user_preferences (user_id, data, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
|
||||
@@ -165,7 +165,8 @@ function update(req, res) {
|
||||
})();
|
||||
res.json({ id: Number(qid) });
|
||||
} 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) {
|
||||
return res.status(500).json({ error: e.message });
|
||||
console.error('[question import]', e.message);
|
||||
return res.status(500).json({ error: 'Ошибка импорта' });
|
||||
}
|
||||
|
||||
res.json({ imported, errors });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const db = require('../db/db');
|
||||
const { awardXP, checkRedBookAchievements } = require('./gamificationController');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────────────── */
|
||||
const stmts = {
|
||||
@@ -86,7 +87,8 @@ function buildSpeciesList(filter = {}) {
|
||||
sql += ' ORDER BY s.category, s.name_ru';
|
||||
const limit = Math.min(parseInt(filter.limit) || 50, 100);
|
||||
const offset = parseInt(filter.offset) || 0;
|
||||
sql += ` LIMIT ${limit} OFFSET ${offset}`;
|
||||
sql += ' LIMIT ? OFFSET ?';
|
||||
params.push(limit, offset);
|
||||
return db.prepare(sql).all(...params);
|
||||
}
|
||||
|
||||
@@ -303,7 +305,8 @@ exports.getSightings = (req, res) => {
|
||||
exports.addSighting = (req, res) => {
|
||||
const { species_id, region_code, description } = req.body || {};
|
||||
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 {}
|
||||
res.json({ id });
|
||||
};
|
||||
|
||||
@@ -59,6 +59,15 @@ function getOne(req, res) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -99,7 +108,8 @@ function addQuestions(req, res) {
|
||||
try {
|
||||
db.transaction(() => { question_ids.forEach(qid => ins.run(testId, qid, idx++)); })();
|
||||
} 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 });
|
||||
}
|
||||
@@ -126,7 +136,8 @@ function reorderQuestions(req, res) {
|
||||
ids.forEach((qid, i) => upd.run(i, testId, qid));
|
||||
})();
|
||||
} 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 });
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ function getErrorLogStmt() {
|
||||
* Honour an incoming X-Request-Id from trusted proxies/gateways when present.
|
||||
*/
|
||||
function requestId(req, res, next) {
|
||||
const id = req.headers['x-request-id'] || crypto.randomUUID();
|
||||
const id = crypto.randomUUID();
|
||||
req.requestId = id;
|
||||
res.setHeader('X-Request-Id', id);
|
||||
next();
|
||||
|
||||
+1
-1
@@ -291,7 +291,7 @@
|
||||
<!-- search -->
|
||||
<div class="search-wrap">
|
||||
<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>
|
||||
|
||||
<!-- filter tabs -->
|
||||
|
||||
@@ -1376,7 +1376,7 @@
|
||||
const ini = name.split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
|
||||
document.getElementById('big-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; }
|
||||
}
|
||||
|
||||
@@ -1395,7 +1395,7 @@
|
||||
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');
|
||||
['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; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user