clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / scripts / qa / ux-http-fuzz.mjs
ux-http-fuzz.mjs 4.8K
1#!/usr/bin/env node2// 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 &amp; <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);20for (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);22for (const q of ["", `?hash=${encodeURIComponent(unusual)}&limit=-1&cursor=%00`, "?sha=%00&hash=not-a-sha"])23 add("file", "/file" + q);24for (const id of ["", "not-an-event", "0".repeat(64), unusual, manifest.notes[0].id]) add("event", "/e/" + encodeURIComponent(id));2526const results = [];27const duplicateIDs = html => [...html.matchAll(/\bid=["']([^"']+)["']/gi)].map(m => m[1]).filter((id, i, all) => all.indexOf(id) !== i);28async 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}40for (const item of cases) results.push(await request(item));4142const valid = [];43const 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};50const note = manifest.notes[0], article = manifest.articles[0], file = manifest.files.find(f => f.label === "markdown");51await validGet("note", `/e/${note.id}`, note.id);52await validGet("article", "/a/ux-long-article", "A long article");53await validGet("articles", "/articles", "A long article");54await validGet("files", "/files", "Files");55await validGet("file", `/file?hash=${file.sha256}`, file.sha256);56await validGet("repos", "/repos", manifest.repository.id);57await validGet("repo home", `/repo?owner=${manifest.owner}&repo=${manifest.repository.id}`, manifest.repository.id);58await validGet("repo tree", `/repo?owner=${manifest.owner}&repo=${manifest.repository.id}&view=tree&ref=refs%2Fheads%2Fmain`, "README UX fixture.md");59await 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");6061const 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 };62await mkdir(dirname(out), { recursive: true }); await writeFile(out, JSON.stringify(report, null, 2) + "\n");63console.log(JSON.stringify({ ok: report.failed === 0, cases: report.cases, valid: report.validCases, passed: report.passed, failed: report.failed, report: out }));64process.exitCode = report.failed ? 1 : 0;