Files
scan-receipts/scripts/verify_sensitive_paths.mjs
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

250 lines
7.6 KiB
JavaScript

/**
* 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;
}
);