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}/`);
});