/** * Verification battery for the sensitive-path / directory-listing hardening * (src/lib/http/sensitivePaths.ts + its wiring in src/middleware.ts). * * Run: node --import ./scripts/register-next-server-resolve.mjs scripts/verify_sensitive_paths.mjs * (or: npx tsx scripts/verify_sensitive_paths.mjs) * * Asserts against the REAL implementation (imported pure function + wrapper), * not a re-implementation: * - blocked paths -> isSensitivePath() === true and blockSensitivePath() * returns a 404 NextResponse with {error:"Not found"} * - allowed paths -> isSensitivePath() === false and blockSensitivePath() * returns undefined (request processing continues) * - encoded bypasses-> blocked by the wrapper's raw-path check * Exits 0 on success, 1 on any failure. */ import { isSensitivePath, blockSensitivePath } from "../src/lib/http/sensitivePaths.ts"; import { NextRequest } from "next/server"; let failures = 0; let checks = 0; function expect(condition, label) { checks++; if (!condition) { failures++; console.error(` FAIL: ${label}`); } } function describe(label, fn) { console.log(`\n${label}`); return fn(); } const BLOCKED_PATHS = [ // dotfiles / dot-directories "/.env", "/.env.local", "/.env.example", "/.git/config", "/.gitignore", "/.dockerignore", "/.npmrc", "/.next/BUILD_ID", "/.next-corrupt-20260815-2345/foo", "/.next-corrupt-20260815-2350/server.js", "/api/.env", // dotfile at depth // non-web-facing directories "/node_modules/x", "/node_modules/next/dist/server.js", "/drizzle/0000_init.sql", "/scripts/generate_demo_images.mjs", "/foo/node_modules/x", // project / build / config files (root + at depth) "/docker-compose.yml", "/docker-compose.override.yml", "/Dockerfile", "/deep/dir/Dockerfile", "/build_err.txt", "/tsconfig.json", "/tsconfig.tsbuildinfo", "/next.config.ts", "/drizzle.config.ts", "/package.json", "/package-lock.json", "/foo/package.json", // markdown documentation "/PROJECT.md", "/ORIGINAL_REQUEST.md", "/PROMPT_GOAL.md", "/DESIGN (1).md", "/AUTH_SETUP_GUIDE.md", "/STRIPE_SETUP_GUIDE.md", "/TEST_INFRA.md", "/receipt_scanner_to_excel_blueprint.md", "/deep/dir/notes.md", // sensitive extensions "/deep/dir/secret.pem", "/deep/dir/private.key", "/deep/dir/cert.crt", "/deep/dir/app.log", // path traversal / malformed input (defensive) "/foo/../bar", "/foo/./bar", "no-leading-slash", "", ]; const ALLOWED_PATHS = [ "/", "/de", "/en", "/dashboard", "/api/receipts", "/api/receipts/123", "/api/auth/login", "/api/auth/callback", "/showcase/01_synthwave_cyberpunk_sunset.png", "/showcase/05_watchmaker_cinematic_photo.jpg", "/app-icon.jpg", "/app-icon-original.png", "/demo/01_aral_tankbeleg_muenchen.jpg", "/favicon.ico", "/icon.png", "/apple-icon.png", "/robots.txt", "/sitemap.xml", "/admin", "/admin/users", "/admin/waitlist", "/admin/system", "/terms", "/privacy", "/impressum", "/datenschutz", "/agb", "/_next/static/chunks/app/layout.js", // excluded by matcher anyway; policy must pass it ]; const ENCODED_CASES = [ // [url, expectBlocked, note] [ "https://example.com/%2eenv", true, "encoded leading dot survives URL parsing -> raw %2e check blocks", ], [ "https://example.com/%252eenv", true, "double-encoded dot -> raw %25 check blocks", ], [ "https://example.com/%2e%2e/%2eenv", true, "encoded .. collapses but %2eenv remains -> raw %2e check blocks", ], [ "https://example.com/foo%5c..%5c.env", true, "encoded backslashes survive URL parsing -> raw %5c check blocks", ], [ "https://example.com/%2e%2e/%2e%2e/etc/passwd", false, "URL parser fully normalizes encoded .. -> neutralized upstream (/etc/passwd is not a route)", ], [ "https://example.com/foo\\bar", false, "URL parser normalizes raw backslash to '/' -> neutralized upstream", ], ]; /** * Wrapper-level subset: every blocked path the wrapper can actually see. * Paths with literal `.`/`..` segments and non-"/" inputs are pure-level * concerns — NextRequest URL parsing normalizes them away (or the middleware * never produces them), so they are asserted separately below. */ const WRAPPER_BLOCKED_PATHS = BLOCKED_PATHS.filter( (p) => p.startsWith("/") && !p.includes("/../") && !p.includes("/./") && p !== ".." ); function mockRequest(url) { return new NextRequest(url); } async function main() { await describe("Pure predicate - blocked paths (expect isSensitivePath() === true)", () => { for (const p of BLOCKED_PATHS) { expect(isSensitivePath(p) === true, `should block ${JSON.stringify(p)}`); } }); await describe("Pure predicate - allowed paths (expect isSensitivePath() === false)", () => { for (const p of ALLOWED_PATHS) { expect(isSensitivePath(p) === false, `should allow ${JSON.stringify(p)}`); } }); await describe("Wrapper - blocked paths return 404 {error:'Not found'}", async () => { for (const p of WRAPPER_BLOCKED_PATHS) { const res = blockSensitivePath(mockRequest(`https://example.com${p}`)); expect(res !== undefined, `should return a response for ${JSON.stringify(p)}`); if (res) { expect(res.status === 404, `status 404 for ${JSON.stringify(p)} (got ${res.status})`); const body = await res.json().catch(() => null); expect( body && typeof body === "object" && body.error === "Not found", `body {error:'Not found'} for ${JSON.stringify(p)} (got ${JSON.stringify(body)})` ); } } }); await describe("Wrapper - allowed paths return undefined (processing continues)", () => { for (const p of ALLOWED_PATHS) { const res = blockSensitivePath(mockRequest(`https://example.com${p}`)); expect(res === undefined, `should continue for ${JSON.stringify(p)}`); } }); await describe("Wrapper - literal traversal segments are neutralized by URL parsing", () => { // NextRequest uses the WHATWG URL parser, which collapses literal // `.`/`..` segments and drops non-"/" inputs before middleware code runs. // The pure predicate still blocks them if they ever appear (defense // against a proxy that forwards un-normalized paths). const neutralized = ["/foo/../bar", "/foo/./bar", "no-leading-slash", ""]; for (const p of neutralized) { expect(isSensitivePath(p) === true, `pure predicate still blocks ${JSON.stringify(p)}`); const res = blockSensitivePath(mockRequest(`https://example.com${p}`)); expect( res === undefined, `wrapper returns undefined for ${JSON.stringify(p)} (URL parser normalized it; router 404s the result)` ); } }); await describe("Wrapper - percent-encoded traversal bypasses", () => { for (const [url, expectBlocked, note] of ENCODED_CASES) { const req = mockRequest(url); // informational: show what nextUrl.pathname looked like after URL parsing console.log(` nextUrl.pathname of ${url} -> ${JSON.stringify(req.nextUrl.pathname)}`); const res = blockSensitivePath(req); if (expectBlocked) { expect(res !== undefined, `should block ${url} (${note})`); if (res) expect(res.status === 404, `status 404 for ${url}`); } else { expect(res === undefined, `neutralized upstream (returns undefined): ${url} (${note})`); } } }); } main().then( () => { console.log(`\n${checks} checks, ${failures} failure(s)`); if (failures > 0) process.exitCode = 1; }, (err) => { console.error("Battery crashed:", err); process.exitCode = 1; } );