SPB Git
3.7 KB · 28 lines typescript
1import "./_env";2/** Black-box security checks against a running instance (default: APP_URL). Usage: tsx scripts/security-audit.ts [base-url] */3const base = (process.argv[2] ?? process.env.APP_URL ?? "http://127.0.0.1:8560").replace(/\/$/, "");4let pass = 0, fail = 0; const out: string[] = [];5const check = (name: string, ok: boolean, detail = "") => { ok ? pass++ : fail++; out.push(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); };6const get = (p: string, init: RequestInit = {}) => fetch(base + p, { redirect: "manual", ...init });7const home = await get("/"); const h = home.headers;8check("HTTP 200 on home", home.status === 200);9for (const [k, v] of [["content-security-policy", "default-src 'self'"], ["x-content-type-options", "nosniff"], ["x-frame-options", "DENY"], ["referrer-policy", "strict-origin"], ["strict-transport-security", "max-age"], ["permissions-policy", "camera=()"]]) check(`header ${k}`, (h.get(k) ?? "").includes(v), h.get(k) ?? "missing");10check("no x-powered-by", !h.get("x-powered-by"));11for (const p of ["/admin", "/admin/papers", "/admin/settings", "/admin/system"]) { const r = await get(p); check(`${p} redirects to /login`, [302, 303, 307].includes(r.status) && (r.headers.get("location") ?? "").includes("/login"), String(r.status)); }12check("/admin noindex", ((await get("/admin")).headers.get("x-robots-tag") ?? "").includes("noindex"));13const up = await get("/api/admin/upload", { method: "POST" }); check("upload without session → 401", up.status === 401, String(up.status));14const pv = await get("/api/admin/preview/" + "a".repeat(64)); check("admin preview without session → 401", pv.status === 401, String(pv.status));15const bad = await get("/login", { method: "POST", headers: { origin: "https://evil.example", "content-type": "application/x-www-form-urlencoded" }, body: "email=a@b.c&password=x" }); check("cross-origin POST to login rejected", bad.status !== 200 || !(await bad.text()).includes("/admin"), String(bad.status));16const ev = await get("/api/events", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ kind: "view", slug: "../etc" }) }); check("events endpoint validates input", ev.status === 204);17const sqli = await get("/api/v1/papers?q=" + encodeURIComponent("' OR 1=1; --")); check("search survives injection attempt", sqli.status === 200);18const tr = await get("/api/files/00000000-0000-0000-0000-000000000000/x.pdf"); check("unknown file id → 404", tr.status === 404);19const tr2 = await get("/api/files/../../etc/passwd"); check("path traversal blocked", tr2.status === 404 || tr2.status === 400 || tr2.status === 308, String(tr2.status));20const reserved = await get("/papers/wp39"); check("reserved identifier not public", reserved.status === 404, String(reserved.status));21const api = await get("/api/v1/papers/wp39"); check("reserved identifier not in API", api.status === 404, String(api.status));22let limited = false; for (let i = 0; i < 70; i++) { const r = await get("/api/v1/search?q=x" + i); if (r.status === 429) { limited = true; break; } } check("search rate limiting (60/min)", limited);23const oai = await get("/api/oai?verb=GetRecord&identifier=oai:wp.spboucher.ai:wp39&metadataPrefix=oai_dc"); check("OAI hides reserved records", (await oai.text()).includes("idDoesNotExist"));24const robots = await (await get("/robots.txt")).text(); check("robots disallows admin", robots.includes("Disallow: /admin"));25const lg = await get("/login"); check("login no-store", (lg.headers.get("cache-control") ?? "").includes("no-store"));26const body = await home.text(); check("no secret-looking strings in home HTML", !/sk-[A-Za-z0-9]{10,}|postgres:\/\/|SESSION_SECRET/.test(body));27console.log(out.join("\n")); console.log(`\n${pass} passed, ${fail} failed (${base})`); process.exit(fail ? 1 : 0);28
Files · f9849d608022d879d08dd07eebfa69fe13695a09
Menu

Theme