From 1dcd39cd1620d1c52b0afe181e13f919066ab152 Mon Sep 17 00:00:00 2001 From: kalepail Date: Thu, 25 Jun 2026 11:29:09 -0400 Subject: [PATCH] harden: treat all GitHub plugin data as untrusted, add tests Builds on the initial sanitization fix. The CLI plugin list is generated from GitHub repos anyone can opt into via the `stellar-cli-plugin` topic, so the name, description, and URL are all attacker-controlled and land in MDX (which renders Markdown, JSX, and raw HTML). HTML-entity escaping alone still left Markdown vectors live: a repo description could inject a phishing link `[text](url)`, a tracking-pixel image `![](url)`, a GFM autolink, or forged headings. Hardening: - sanitizeText now collapses whitespace to a single line (defeating block-level Markdown and multi-line attribute tricks) and backslash-escapes every ASCII punctuation character, so the value can only ever render as literal text. CommonMark/MDX guarantee `\` + punctuation renders as the literal char. - safeUrl allow-lists `https://github.com/...` only; other hosts and `javascript:` / `data:` schemes drop the item (allow-list, not deny-list). - Add a main-module guard so helpers are importable without the network fetch, and a node:test suite covering script/brace/link/image/autolink injection and URL validation. Run with: node --test scripts/stellar_cli_plugins.test.mjs Co-Authored-By: Claude Opus 4.8 --- scripts/stellar_cli_plugins.mjs | 108 ++++++++++++++++++--------- scripts/stellar_cli_plugins.test.mjs | 96 ++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 35 deletions(-) create mode 100644 scripts/stellar_cli_plugins.test.mjs diff --git a/scripts/stellar_cli_plugins.mjs b/scripts/stellar_cli_plugins.mjs index 85b717f6e..fad7400aa 100644 --- a/scripts/stellar_cli_plugins.mjs +++ b/scripts/stellar_cli_plugins.mjs @@ -1,20 +1,43 @@ import fs from "fs-extra"; import https from "https"; +import { fileURLToPath } from "url"; // In case there are plugins to exclude from the list, add them here. // E.g. "user/repo" const excludePlugins = ["haqnawaz03329-debug/haqnawaz"]; -// GitHub repo descriptions are attacker-controlled (anyone can tag a repo with -// the `stellar-cli-plugin` topic), and this content is injected into MDX, which -// renders HTML/JSX. Neutralize characters that could execute as markup/script. -function sanitize(value) { - return (value || "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/{/g, "{") - .replace(/}/g, "}"); +// The plugin list is built from GitHub repos that anyone can opt into by adding +// the `stellar-cli-plugin` topic, so every field below (name, description, URL) +// is attacker-controlled and ends up in an .mdx file. MDX renders Markdown, +// JSX and raw HTML, so unsanitized text allows stored XSS (e.g. a `"); + assertFullyNeutralized(out); + assert.ok(!/(^|[^\\]) { + const out = sanitizeText("danger {process.env.SECRET} here"); + assertFullyNeutralized(out); + assert.ok(!/(^|[^\\])[{}]/.test(out), "no unescaped brace remains"); +}); + +test("sanitizeText neutralizes Markdown links (phishing)", () => { + const out = sanitizeText("[Official — install now](https://evil.example.com)"); + assert.ok(!out.includes("]("), "link syntax must be broken"); + assertFullyNeutralized(out); +}); + +test("sanitizeText neutralizes Markdown images (tracking pixels)", () => { + const out = sanitizeText("![pixel](https://evil.example.com/p.png)"); + assert.ok(!out.includes("!["), "image syntax must be broken"); + assertFullyNeutralized(out); +}); + +test("sanitizeText neutralizes GFM autolinks", () => { + const out = sanitizeText("visit http://evil.example.com now"); + assert.ok(!out.includes("http://"), "bare URL scheme must be broken"); + assertFullyNeutralized(out); +}); + +test("sanitizeText collapses newlines so block constructs cannot start", () => { + const out = sanitizeText("line one\n\n### Fake heading\n> quote"); + assert.ok(!out.includes("\n"), "must be a single line"); + assertFullyNeutralized(out); +}); + +test("sanitizeText leaves plain prose untouched but escapes punctuation", () => { + // Plain words render unchanged; only punctuation gains a backslash, which the + // Markdown renderer consumes, so the visible output matches the original text. + assert.equal(sanitizeText("hello world"), "hello world"); + assert.equal(sanitizeText("a & b"), "a \\& b"); + assert.equal(sanitizeText("emphasis _here_ and *there*"), "emphasis \\_here\\_ and \\*there\\*"); +}); + +test("sanitizeText handles null/undefined", () => { + assert.equal(sanitizeText(null), ""); + assert.equal(sanitizeText(undefined), ""); +}); + +test("safeUrl accepts canonical GitHub repo URLs", () => { + assert.equal( + safeUrl("https://github.com/stellar/stellar-docs"), + "https://github.com/stellar/stellar-docs", + ); +}); + +test("safeUrl rejects javascript: scheme", () => { + assert.equal(safeUrl("javascript:alert(1)"), null); +}); + +test("safeUrl rejects data: scheme", () => { + assert.equal(safeUrl("data:text/html,"), null); +}); + +test("safeUrl rejects non-https schemes", () => { + assert.equal(safeUrl("http://github.com/a/b"), null); +}); + +test("safeUrl rejects look-alike and other hosts", () => { + assert.equal(safeUrl("https://github.com.evil.com/a/b"), null); + assert.equal(safeUrl("https://evil.com/a/b"), null); +}); + +test("safeUrl rejects malformed input", () => { + assert.equal(safeUrl("not a url"), null); + assert.equal(safeUrl(null), null); +});