#!/bin/bash # check-route-auth.sh - List backend routes with NO auth guard in their # registration block, so unintentionally-public endpoints are visible. # # Why: there is no global auth hook. Every route must opt in via # preHandler: [fastify.authenticate] / requireAdmin / requireTier # (backend/src/core/plugins/*.plugin.ts). A forgotten preHandler ships a # public endpoint silently. # # Usage: ./check-route-auth.sh (read-only; run from anywhere in repo) # Exit: 0 = only known-public routes unguarded, 1 = unexpected unguarded route set -u ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" [ -z "$ROOT" ] && { echo "ERROR: not inside a git repo" >&2; exit 2; } cd "$ROOT" || exit 2 # CANONICAL intentional-public-route list (verified 2026-07-09). This comment # is the ONE home for it; mvp-architecture-contract invariant 2.1 and # mvp-debugging-playbook section 7 point here instead of keeping their own lists. # # Within this script's scan scope (backend/src/features/*/api/*.routes.ts), # allowlisted via KNOWN_PUBLIC below: # /webhooks/stripe, /webhooks/resend/inbound - provider signature auth # /auth/signup, /auth/resend-verification-public - pre-auth flows # (comments in auth.routes.ts: "public, no JWT required") # # OUTSIDE this script's scan scope (registered in core code, also intentionally # public - the script can neither flag nor clear them): # /health, /api/health - backend/src/app.ts health endpoints # /auth/verify - backend/src/app.ts (Traefik forward-auth) # GET /api/config/feature-tiers - backend/src/core/config/config.routes.ts # (no preHandler; config is not sensitive) KNOWN_PUBLIC='/webhooks/stripe /webhooks/resend/inbound /auth/signup /auth/resend-verification-public' OUT=$(awk ' function flush() { if (inblock && !guarded) printf "%s:%d %s %s\n", f, startline, method, path inblock = 0 } FNR == 1 { flush() } /fastify\.(get|post|put|patch|delete)[<(]/ { flush() inblock = 1; guarded = 0; startline = FNR; path = "?"; f = FILENAME match($0, /fastify\.(get|post|put|patch|delete)/) method = toupper(substr($0, RSTART + 8, RLENGTH - 8)) } inblock && path == "?" { if (match($0, "\047/[^\047]*\047")) path = substr($0, RSTART + 1, RLENGTH - 2) } # requireAuth is the common local alias: const requireAuth = fastify.authenticate.bind(fastify) inblock && /[ .\[](authenticate|requireAuth|requireAdmin|requireTier)[ ,\]\}\)\(]/ { guarded = 1 } END { flush() } ' backend/src/features/*/api/*.routes.ts) TOTAL=$(grep -c 'fastify\.\(get\|post\|put\|patch\|delete\)[<(]' \ backend/src/features/*/api/*.routes.ts | awk -F: '{s+=$2} END {print s}') echo "Route registrations scanned: $TOTAL" echo FAIL=0 if [ -z "$OUT" ]; then echo "PASS: every route block contains an auth guard." exit 0 fi echo "Routes with NO authenticate/requireAdmin/requireTier in their block:" echo "$OUT" | while read -r line; do p=$(echo "$line" | awk '{print $3}') case " $KNOWN_PUBLIC " in *" $p "*) echo " OK (known-public, allowlisted): $line" ;; *) echo " UNGUARDED: $line" ;; esac done # Re-count outside the pipe subshell (bash 3.2: while-in-pipe loses vars) FAIL=$(echo "$OUT" | awk -v kp=" $KNOWN_PUBLIC " '{ if (index(kp, " " $3 " ") == 0) n++ } END {print n+0}') echo if [ "$FAIL" -eq 0 ]; then echo "PASS: all unguarded routes are on the known-public allowlist." exit 0 else echo "FAIL: $FAIL route(s) unguarded and not on the allowlist." echo "Either add a preHandler guard or, if intentionally public, add the" echo "path to KNOWN_PUBLIC in this script with a justification comment." echo "Limitation: block-scope heuristic; a guard mentioned in a comment" echo "inside the block can mask a real gap - verify hits by reading code." exit 1 fi