12// Read-only HTTP fuzzing for the browser-facing hosted-content viewers.3import { readFile, mkdir, writeFile } from "node:fs/promises";4import { dirname, resolve } from "node:path";56const args = process.argv.slice(2);7const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key) + 1] : fallback;8const manifestPath = resolve(option("--manifest", "output/playwright/ux/fixture.json"));9const manifest = JSON.parse(await readFile(manifestPath, "utf8"));10const base = (option("--relay", manifest.relay)).replace(/\/$/, "");11const out = resolve(option("--out", "output/playwright/ux/http-fuzz.json"));12const timeoutMs = Number(option("--timeout-ms", "5000"));13const unusual = " café/路径 #hash ?query %percent & <script>alert(1)</script>\u0000";14const prefixes = ["", "/r/main"];15const cases = [];16const add = (name, path, query = "") => { for (const prefix of prefixes) cases.push({ name: `${prefix || "root"} ${name}`, url: `${base}${prefix}${path}${query}` }); };1718for (const q of ["", "?limit=-1&cursor=%00", `?q=${encodeURIComponent(unusual)}&limit=999999`, "?owner=%00&ref=refs%2Fheads%2Fmain&path=%2F..%2F"])19 add("search", "/search" + q);20const repoPage = (owner, repo, rest = "") => `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}${rest}`;21for (const path of ["/repos/", repoPage(unusual, unusual), repoPage(unusual, unusual, `/tree/${encodeURIComponent(unusual)}?ref=%00`), repoPage(unusual, unusual, `/file/${encodeURIComponent(unusual)}?ref=%00`), repoPage("\u0000", "\u0000", "/history?limit=NaN&cursor=%00"), repoPage("\u0000", "\u0000", "/commit/%00"), repoPage("\u0000", "\u0000", "/issues/not-an-id"), repoPage("\u0000", "\u0000", "/prs/%00")])22 add("repo", path);23for (const path of ["/file/", `/file/${encodeURIComponent(unusual)}?limit=-1&cursor=%00`, "/file/%00", "/file/not-a-sha", "/file?hash=" + "0".repeat(64)])24 add("file", path);25for (const path of ["/approvals/", "/approvals/%00", "/approvals/not-an-id", "/approvals?id=" + "0".repeat(64)])26 add("approval", path);27for (const id of ["", "not-an-event", "0".repeat(64), unusual, manifest.notes[0].id]) add("event", "/e/" + encodeURIComponent(id));2829const results = [];30const duplicateIDs = html => [...html.matchAll(/\bid=["']([^"']+)["']/gi)].map(m => m[1]).filter((id, i, all) => all.indexOf(id) !== i);31async function request(item) {32 const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs);33 const started = performance.now();34 try {35 const response = await fetch(item.url, { method: "GET", redirect: "manual", signal: controller.signal });36 const body = await response.text();37 const duplicate = duplicateIDs(body);38 const injected = /<script\s*>\s*alert\s*\(/i.test(body) || body.includes("<script>alert(1)</script>");39 return { ...item, status: response.status, ms: Math.round(performance.now() - started), bytes: body.length, ok: response.status < 500 && !injected && duplicate.length === 0, checks: { no5xx: response.status < 500, noScriptInjection: !injected, noDuplicateIDs: duplicate.length === 0 }, ...(duplicate.length ? { duplicateIDs: duplicate } : {}) };40 } catch (error) { return { ...item, status: 0, ms: Math.round(performance.now() - started), ok: false, error: error.name === "AbortError" ? "timeout" : error.message }; }41 finally { clearTimeout(timer); }42}43for (const item of cases) results.push(await request(item));4445const valid = [];46const validGet = async (name, path, expected) => {47 const item = { name: `valid ${name}`, url: base + path }; const row = await request(item);48 const body = row.status ? await (await fetch(item.url)).text() : "";49 row.checks = { ...row.checks, expected: body.includes(expected), noClassAttributes: !/\bclass\s*=/i.test(body) };50 row.ok = row.ok && row.checks.expected && row.checks.noClassAttributes;51 valid.push(row);52};53const note = manifest.notes[0], article = manifest.articles[0], file = manifest.files.find(f => f.label === "markdown");54await validGet("note", `/e/${note.id}`, note.id);55await validGet("article", "/a/ux-long-article", "A long article");56await validGet("articles", "/articles", "A long article");57await validGet("files", "/files", "Files");58await validGet("file", `/file/${file.sha256}`, file.sha256);59await validGet("repos", "/repos", manifest.repository.id);60await validGet("repo home", repoPage(manifest.owner, manifest.repository.id), manifest.repository.id);61await validGet("repo tree", repoPage(manifest.owner, manifest.repository.id, "/tree?ref=refs%2Fheads%2Fmain"), "README UX fixture.md");62await validGet("repo file", repoPage(manifest.owner, manifest.repository.id, "/file/" + "nested dir/Unicode café/emoji 🧪.md".split("/").map(encodeURIComponent).join("/") + "?ref=refs%2Fheads%2Fmain"), "unicode path content");6364const report = { schema: 1, fixture: manifest.fixture, relay: base, generatedAt: new Date().toISOString(), cases: cases.length, fuzz: results.length, validCases: valid.length, passed: [...results, ...valid].filter(r => r.ok).length, failed: [...results, ...valid].filter(r => !r.ok).length, results, valid };65await mkdir(dirname(out), { recursive: true }); await writeFile(out, JSON.stringify(report, null, 2) + "\n");66console.log(JSON.stringify({ ok: report.failed === 0, cases: report.cases, valid: report.validCases, passed: report.passed, failed: report.failed, report: out }));67process.exitCode = report.failed ? 1 : 0;