Files
Learn_System/backend/scripts/check-route-auth.js
T
Maxim Dolgolyov 900fdb893d 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>
2026-06-11 23:00:19 +03:00

193 lines
7.0 KiB
JavaScript

#!/usr/bin/env node
/**
* check-route-auth.js — scans all Express route files for :id-bearing
* routes that lack an auth-guard middleware.
*
* HOW TO USE:
* npm run lint:routes
*
* MARKING A ROUTE AS INTENTIONALLY PUBLIC:
* Add a comment on the line immediately before the route definition:
* // @public-by-design: <reason>
* router.get('/:id', handler);
*
* ADDING A GUARD (preferred):
* router.get('/:id', requireOwnership({ table: 'tests', ownerField: 'created_by' }), handler);
* router.get('/:id', requireRole('admin'), handler);
* router.get('/:id', requirePermission('questions.manage'), handler);
* router.get('/:id', parentAuth, handler);
* router.get('/:id', authMiddleware, handler); // generic auth check
*
* AVAILABLE GUARDS (backend/src/middleware/):
* requireOwnership — verifies resource belongs to req.user.id
* requireRole — requires specific role(s)
* requirePermission — checks granular permission key
* parentAuth — JWT auth for parent-link tokens
* authMiddleware — basic auth (no ownership check)
*
* BASELINE:
* The script allows up to BASELINE unprotected routes (set from first run).
* Any new unprotected :id route causes exit(1). Reduce BASELINE over time
* as old routes are migrated.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const registry = require('../src/permissions/registry');
const ROUTES_DIR = path.join(__dirname, '../src/routes');
// Auth-guard identifiers that count as protection
const GUARDS = [
'requireOwnership',
'requireRole',
'requirePermission',
'perm', // ergonomic alias from auth.js that validates key at registration time
'parentAuth',
'authMiddleware',
'requireAuth',
'ownsTest', // alias used in tests.js
];
// Baseline: number of unprotected :id-routes.
// 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();
// Match: router.METHOD('.../:id...', ...)
// Allow multi-segment params like /:id/strokes, /:classId/members/:userId
if (!/^router\.(get|post|put|patch|delete)\s*\(/.test(line)) continue;
// Does path contain a :param?
const pathMatch = line.match(/router\.\w+\s*\(\s*['"`]([^'"`]+)['"`]/);
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;
while (j < lines.length && !callText.includes(');') && j < i + 10) {
callText += ' ' + lines[j].trim();
j++;
}
// Check if any guard is present in the call arguments.
// Also treat spread middleware arrays (...auth, ...teacher) as guards.
const hasGuard = GUARDS.some(g => callText.includes(g))
|| /\.\.\.[a-zA-Z_]\w*/.test(callText); // spread like ...auth, ...teacher
if (hasGuard) continue;
// Check for @public-by-design comment on the preceding non-empty line
let prev = i - 1;
while (prev >= 0 && lines[prev].trim() === '') prev--;
const prevLine = prev >= 0 ? lines[prev].trim() : '';
if (prevLine.startsWith('// @public-by-design:')) continue;
issues.push({
file: path.basename(filePath),
line: i + 1,
route: line.slice(0, 100),
});
}
return issues;
}
function main() {
const files = fs.readdirSync(ROUTES_DIR)
.filter(f => f.endsWith('.js'))
.map(f => path.join(ROUTES_DIR, f));
let total = 0;
let unprotected = 0;
const allIssues = [];
for (const file of files) {
const issues = scanFile(file);
const routeMatches = fs.readFileSync(file, 'utf8')
.split('\n')
.filter(l => /^router\.(get|post|put|patch|delete)\s*\(/.test(l.trim()) && l.includes(':'));
total += routeMatches.length;
unprotected += issues.length;
allIssues.push(...issues);
}
if (allIssues.length > 0) {
console.log('\nUnprotected :id-routes (no auth-guard, no @public-by-design comment):');
for (const { file, line, route } of allIssues) {
console.log(` ${file}:${line} ${route}`);
}
console.log();
}
console.log(`Route auth check: ${total} :id-routes total, ${unprotected} unprotected (baseline: ${BASELINE})`);
/* ── Permission key validation ───────────────────────────────────────── */
// Scan all route files for requirePermission('X') and perm('X') calls.
// Verify each key exists in the central registry.
const permKeyRe = /(?:requirePermission|perm)\(\s*['"`]([^'"`]+)['"`]/g;
const unknownPermKeys = [];
for (const file of files) {
const src = fs.readFileSync(file, 'utf8');
let m;
permKeyRe.lastIndex = 0;
while ((m = permKeyRe.exec(src)) !== null) {
const key = m[1];
if (!registry.isKnown(key)) {
unknownPermKeys.push({ file: path.basename(file), key });
}
}
}
if (unknownPermKeys.length > 0) {
console.error('\nFAIL: requirePermission/perm calls with keys not in registry:');
for (const { file, key } of unknownPermKeys) {
console.error(` ${file}: "${key}"`);
}
console.error('Add missing keys to backend/src/permissions/registry.js');
process.exit(1);
} else {
console.log('Permission key check: all requirePermission/perm keys are registered.');
}
if (unprotected > BASELINE) {
console.error(`\nFAIL: ${unprotected - BASELINE} new unprotected route(s) added above baseline.`);
console.error('Add requireOwnership/requireRole/requirePermission, or mark intentional:');
console.error(' // @public-by-design: <reason>');
process.exit(1);
}
if (unprotected < BASELINE) {
console.log(`\nBaseline can be lowered from ${BASELINE} to ${unprotected} — update BASELINE in check-route-auth.js`);
}
process.exit(0);
}
main();