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>
250 lines
7.6 KiB
JavaScript
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;
|
|
}
|
|
);
|