chore: route auth-guard linter (baseline 56 unprotected :id-routes)
Scans all routes/*.js for :id-bearing routes without an auth-guard
(requireOwnership, requireRole, requirePermission, authMiddleware,
parentAuth, or spread middleware arrays like ...auth/...teacher).
BASELINE=56 — any new unprotected :id route causes exit(1).
Reduce BASELINE as old routes are migrated.
Usage:
npm run lint:routes
# or mark intentional public routes:
// @public-by-design: <reason>
router.get('/:token', handler);
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
#!/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 ROUTES_DIR = path.join(__dirname, '../src/routes');
|
||||
|
||||
// Auth-guard identifiers that count as protection
|
||||
const GUARDS = [
|
||||
'requireOwnership',
|
||||
'requireRole',
|
||||
'requirePermission',
|
||||
'parentAuth',
|
||||
'authMiddleware',
|
||||
'requireAuth',
|
||||
'ownsTest', // alias used in tests.js
|
||||
];
|
||||
|
||||
// Baseline: number of unprotected :id-routes found on 2026-05-06.
|
||||
// ONLY decrease this over time — never increase it.
|
||||
const BASELINE = 56;
|
||||
|
||||
function scanFile(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const issues = [];
|
||||
|
||||
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;
|
||||
|
||||
// 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})`);
|
||||
|
||||
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();
|
||||
Reference in New Issue
Block a user