diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1afa2b0..6219672 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true diff --git a/app/src/components/launchpad/image-upload.test.ts b/app/src/components/launchpad/image-upload.test.ts new file mode 100644 index 0000000..06fe572 --- /dev/null +++ b/app/src/components/launchpad/image-upload.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +type PreviewInput = { value: string; compact?: boolean }; + +/** + * Render the real client component and React's attribute serializer. The main + * test command selects react-server exports, so this child deliberately starts + * without that condition: useState/useRef and react-dom/server need the normal + * React entry points. Static rendering performs no image requests or uploads. + */ +function renderPreviews(inputs: PreviewInput[]): string[] { + const result = spawnSync(process.execPath, ["--import", "tsx", "--eval", ` + const { readFileSync } = require("node:fs"); + const { createElement } = require("react"); + const { renderToStaticMarkup } = require("react-dom/server"); + const ImageUpload = require("./src/components/launchpad/ImageUpload.tsx").default; + const inputs = JSON.parse(readFileSync(0, "utf8")); + const output = inputs.map((props) => renderToStaticMarkup(createElement(ImageUpload, { + ...props, wallet: undefined, onChange() { throw new Error("Rendering must not upload or change the URL"); }, + }))); + process.stdout.write(JSON.stringify(output)); + `], { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + input: JSON.stringify(inputs), + encoding: "utf8", + timeout: 10_000, + windowsHide: true, + }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout) as string[]; +} + +test("image previews retain HTTP(S), trim whitespace, and preserve compact sizing", () => { + const [https, http, compact] = renderPreviews([ + { value: " https://images.example/logo.png " }, + { value: "http://images.example/logo.gif" }, + { value: "https://images.example/logo.webp", compact: true }, + ]); + assert.ok(https.includes('Change image

")); + } +}); + +test("non-HTTP(S) input never becomes an image preview", () => { + const values = [ + "", " ", + "javascript:alert(1)", " \tjavascript:alert(1)\n", + "JaVaScRiPt:alert(1)", "java\nscript:alert(1)", + "data:text/html,", + "data:image/svg+xml,", + "//images.example/logo.png", "/logo.png", "https:images.example/logo.png", + '"/>', + ]; + const output = renderPreviews(values.map((value) => ({ value }))); + for (const [index, html] of output.entries()) { + assert.equal(html.includes("Upload image

")); + } +}); + +test("quotes and markup in an HTTP(S) URL remain encoded attribute data", () => { + const value = 'https://images.example/logo.png?x=" onerror="alert(1)">&y=\'test\''; + const escaped = "https://images.example/logo.png?x=" onerror="alert(1)"><script>alert(2)</script>&y='test'"; + const [html] = renderPreviews([{ value }]); + // Assert the actual serialized sink, not a duplicate sanitization function or + // a source-code pattern. React must keep the entire value in one src attribute. + assert.ok(html.includes(`"), false); + assert.equal(html.includes(""), false); +}); diff --git a/app/src/lib/ci-permissions.test.ts b/app/src/lib/ci-permissions.test.ts new file mode 100644 index 0000000..f4deee1 --- /dev/null +++ b/app/src/lib/ci-permissions.test.ts @@ -0,0 +1,11 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +test("build-only CI declares read-only token permissions for both jobs", () => { + const workflow = readFileSync(new URL("../../../.github/workflows/ci.yml", import.meta.url), "utf8"); + const permissions = workflow.match(/^permissions:[ \t]*\r?\n((?:[ \t]+[^\r\n]*\r?\n)+)/m)?.[1]; + assert.ok(permissions, "CI must explicitly declare workflow-level token permissions"); + assert.deepEqual(permissions.trim().split(/\r?\n/).map((line) => line.trim()), ["contents: read"]); + assert.equal([...workflow.matchAll(/^[ \t]*permissions:/gm)].length, 1, "both jobs inherit the read-only policy without overrides"); +}); diff --git a/app/src/lib/launchpad/posts.test.ts b/app/src/lib/launchpad/posts.test.ts index 81afb11..458337e 100644 --- a/app/src/lib/launchpad/posts.test.ts +++ b/app/src/lib/launchpad/posts.test.ts @@ -25,6 +25,50 @@ test("post message binds chain, token, wallet, reply, nonce, time and body", () assert.ok(buildModMessage({ action: "hide", target: "post:3", wallet: "0xCD", nonce: "n", ts: 0 }).includes("Action: hide")); }); +test("validateBody preserves every code unit except the intended controls and CR normalization", () => { + for (let unit = 0; unit <= 0xffff; unit++) { + const character = String.fromCharCode(unit); + const stripped = unit <= 0x08 || unit === 0x0b || unit === 0x0c + || (unit >= 0x0e && unit <= 0x1f) || unit === 0x7f; + const expected = stripped ? "" : unit === 0x0d ? "\n" : character; + // Sentinels keep whitespace inside the body, independent of edge trimming. + assert.deepEqual(validateBody(`a${character}z`), { ok: true, body: `a${expected}z` }, `code unit ${unit.toString(16)}`); + } +}); + +test("validateBody retains text whitespace and Unicode while normalizing line endings", () => { + assert.deepEqual(validateBody(" \tfirst\tsecond\r\nthird\rfourth\n\n\nfifth\t "), { + ok: true, + body: "first\tsecond\nthird\nfourth\n\nfifth", + }); + const unicode = "தமிழ் café e\u0301 👩‍💻 🚀 — \u200b\u0085\u009f"; + assert.deepEqual(validateBody(unicode), { ok: true, body: unicode }); + assert.deepEqual(validateBody("a\u0000b\u0007c\u0008d\u000be\u000cf\u000eg\u001fh\u007f"), { + ok: true, + body: "abcdefgh", + }); + assert.deepEqual(validateBody("\u0000\u0008\u000b\u000c\u000e\u001f\u007f"), { ok: false, error: "empty post" }); + assert.deepEqual(validateBody("x".repeat(500) + "\u0000\u001f\u007f"), { ok: true, body: "x".repeat(500) }); +}); + +test("normalized post bodies retain their byte-exact signed message", () => { + const result = validateBody(" \tgm\u0000\tfrens\r\nதமிழ் 🚀 — open\u007f "); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(buildPostMessage({ chain: "base", token: "0xAB", wallet: "0xCD", nonce: "n", ts: 0, parentId: 7, body: result.body }), [ + "openlaunch.lol \u2014 sign to post. Free, no transaction.", + "", + "Chain: base", + "Token: 0xab", + "Wallet: 0xcd", + "Reply to: 7", + "Nonce: n", + "Time: 1970-01-01T00:00:00.000Z", + "", + "gm\tfrens\nதமிழ் 🚀 — open", + ].join("\n")); +}); + test("copy cleanup preserves byte-exact wallet signature headers and user text", () => { const body = "hello \u2014 world"; const message = buildPostMessage({ chain: "base", token: "0xAB", wallet: "0xCD", nonce: "n", ts: 0, parentId: null, body }); diff --git a/app/src/lib/launchpad/posts.ts b/app/src/lib/launchpad/posts.ts index 881bf81..b6deaaa 100644 Binary files a/app/src/lib/launchpad/posts.ts and b/app/src/lib/launchpad/posts.ts differ diff --git a/app/src/lib/seo.test.ts b/app/src/lib/seo.test.ts index 5888a3c..e0c2db7 100644 --- a/app/src/lib/seo.test.ts +++ b/app/src/lib/seo.test.ts @@ -42,7 +42,9 @@ test("static sitemap covers every public route and skips /admin", () => { assert.ok(!paths.includes("/admin"), "/admin must stay out of the sitemap"); const entries = staticSitemapEntries(SITE, "2026-09-06T00:00:00.000Z"); assert.equal(entries.length, STATIC_SITEMAP_ROUTES.length); - assert.ok(entries.every((e) => e.url.startsWith(SITE))); + for (const entry of entries) { + assert.equal(new URL(entry.url).origin, new URL(SITE).origin); + } assert.equal(entries[0].url, `${SITE}/`); });