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