security(routes): закрыт долг по незащищённым :id-маршрутам (baseline 66→0)

check-route-auth теперь распознаёт router-level guards (router.use(<guard>)) —
ушли ложные срабатывания (admin/permissions/flashcards/lessons/… защищены на
уровне роутера, что линтер уже принимает как authMiddleware). Из 66 осталось
8 действительно безавторизационных :id-маршрутов — все публичные по дизайну
(гостевая доска по секретному токену, справочные данные Red Book, список тем
предмета): помечены @public-by-design после проверки (мутации требуют auth).
Baseline опущен до 0 — новые незащищённые маршруты теперь сразу падают в хуке.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-11 23:00:19 +03:00
parent 9cfb7d1c3b
commit 900fdb893d
4 changed files with 28 additions and 5 deletions
+20 -5
View File
@@ -51,17 +51,29 @@ const GUARDS = [
];
// Baseline: number of unprotected :id-routes.
// Reconciled 2026-06-11: drifted 56→66 via branch merges (lab-content-engine,
// red-book, exam-prep и др.) — pre-commit hook не запускается на merge, поэтому
// маршруты пришли без проверки. Это уже смерженный долг, а не новый риск.
// ONLY decrease this over time — never increase it (кроме сверки с уже смерженным).
const BASELINE = 66;
// 2026-06-11: линтер научился видеть router-level guards (router.use(<guard>)),
// что убрало ложные срабатывания (admin/permissions/flashcards/… защищены на
// уровне роутера). Оставшиеся 8 публичных маршрутов (guest-доска по токену,
// справочные данные red-book, список тем) помечены @public-by-design. Долг закрыт.
// ONLY decrease this over time — never increase it.
const BASELINE = 0;
function scanFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
const issues = [];
// Router-level guard: `router.use(<guard>)` without a leading path string
// protects every route declared after it (same guards accepted inline).
// Find the earliest such line so those routes aren't false-flagged.
let globalGuardLine = Infinity;
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (!t.startsWith('router.use(')) continue;
if (/^router\.use\(\s*['"`]/.test(t)) continue; // path-scoped — not global
if (GUARDS.some(g => t.includes(g))) { globalGuardLine = i; break; }
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
@@ -74,6 +86,9 @@ function scanFile(filePath) {
if (!pathMatch) continue;
if (!pathMatch[1].includes(':')) continue;
// Protected by a router-level guard declared earlier in this file
if (i > globalGuardLine) continue;
// Collect the full route call (may span multiple lines)
let callText = line;
let j = i + 1;
+5
View File
@@ -40,6 +40,7 @@ function notifySession(sessionId, data) {
}
/* ── GET /api/classroom/guest/:token ─── session info (pre-join screen) */
// @public-by-design: гостевой доступ по секретному токену (read-only доска)
router.get('/:token', (req, res) => {
const session = sessionByToken(req.params.token);
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
@@ -58,6 +59,7 @@ router.get('/:token', (req, res) => {
});
/* ── POST /api/classroom/guest/:token/join ─── choose name, get guestId */
// @public-by-design: гостевой доступ по секретному токену
router.post('/:token/join', (req, res) => {
const session = sessionByToken(req.params.token);
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
@@ -92,6 +94,7 @@ router.post('/:token/join', (req, res) => {
});
/* ── GET /api/classroom/guest/:token/strokes ─── whiteboard strokes */
// @public-by-design: гостевой доступ по секретному токену
router.get('/:token/strokes', (req, res) => {
const session = sessionByToken(req.params.token);
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
@@ -118,6 +121,7 @@ router.get('/:token/strokes', (req, res) => {
});
/* ── GET /api/classroom/guest/:token/stream ─── SSE */
// @public-by-design: гостевой доступ по секретному токену (SSE)
router.get('/:token/stream', (req, res) => {
const session = sessionByToken(req.params.token);
if (!session) return res.status(404).end();
@@ -150,6 +154,7 @@ router.get('/:token/stream', (req, res) => {
});
/* ── POST /api/classroom/guest/:token/leave ─── explicit goodbye */
// @public-by-design: гостевой доступ по секретному токену
router.post('/:token/leave', (req, res) => {
const guestId = req.body?.guestId;
const guest = guestId ? guests.get(guestId) : null;
+2
View File
@@ -11,7 +11,9 @@ router.get('/map-data', ctrl.getMapData);
router.get('/food-web', ctrl.getFoodWeb);
router.get('/daily', optionalAuth, ctrl.getDaily);
router.get('/species', optionalAuth, ctrl.getSpecies);
// @public-by-design: публичные справочные данные о виде (read-only)
router.get('/species/:id', optionalAuth, ctrl.getSpeciesById);
// @public-by-design: публичные справочные данные о биоме (read-only)
router.get('/biome/:habitatId', ctrl.getBiomeSpecies);
// Auth required
+1
View File
@@ -30,6 +30,7 @@ router.patch('/:slug', authMiddleware, requireRole('admin'), (req, res) => {
res.json(db.prepare('SELECT * FROM subjects WHERE slug = ?').get(req.params.slug));
});
// @public-by-design: публичный список тем предмета (read-only каталог)
router.get('/:slug/topics', (req, res) => {
const subject = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(req.params.slug);
if (!subject) return res.status(404).json({ error: 'Subject not found' });