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