From 513ec059bf68fa6faa4108fcdbcf3d3ab2c75b24 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 6 May 2026 17:02:17 +0300 Subject: [PATCH] chore: route auth-guard linter (baseline 56 unprotected :id-routes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: router.get('/:token', handler); Co-Authored-By: Claude Sonnet 4.6 --- backend/package.json | 1 + backend/scripts/check-route-auth.js | 145 ++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 backend/scripts/check-route-auth.js diff --git a/backend/package.json b/backend/package.json index 846d3bf..1870ec2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,6 +9,7 @@ "migrate": "node src/db/migrate.js", "seed": "node src/db/seed.js", "seed:permissions": "node src/db/seed-permissions.js", + "lint:routes": "node scripts/check-route-auth.js", "test": "node --test tests/*.test.js" }, "dependencies": { diff --git a/backend/scripts/check-route-auth.js b/backend/scripts/check-route-auth.js new file mode 100644 index 0000000..a032e98 --- /dev/null +++ b/backend/scripts/check-route-auth.js @@ -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: + * 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: '); + 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();