Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 73 additions & 35 deletions scripts/stellar_cli_plugins.mjs
Original file line number Diff line number Diff line change
@@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/{/g, "&#123;")
.replace(/}/g, "&#125;");
// 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 `<script>` in
// a repo description) as well as phishing links and tracking images via plain
// Markdown. We therefore treat all GitHub-provided text as untrusted.

// Render an untrusted string as literal text. Collapsing whitespace to a single
// line defeats block-level Markdown (headings, lists, blockquotes, code fences)
// and multi-line attribute tricks; backslash-escaping every ASCII punctuation
// character then neutralizes all inline Markdown, GFM autolinks, and JSX/HTML —
// CommonMark guarantees `\` + ASCII punctuation renders as the literal char, and
// MDX supports `\<` / `\{` escaping, so nothing is interpreted as markup.
function sanitizeText(value) {
return String(value || "")
.replace(/\s+/g, " ")
.trim()
.replace(/[!-/:-@[-`{-~]/g, (c) => "\\" + c);
}

// Allow-list the only URL shape GitHub's API returns for these repos. Anything
// else (other hosts, or `javascript:` / `data:` schemes) is rejected so a
// malformed/hostile URL can never become a link target.
function safeUrl(value) {
try {
const url = new URL(String(value));
if (url.protocol !== "https:") return null;
if (url.hostname !== "github.com") return null;
return url.href;
} catch {
return null;
}
}
Comment on lines +32 to 41

function exportMDX(data) {
Expand All @@ -23,11 +46,16 @@ function exportMDX(data) {
return buffer;
}

const plugin = `### [${sanitize(item.full_name)}](${encodeURI(item.html_url)})
const url = safeUrl(item.html_url);
if (!url) {
return buffer;
}

const plugin = `### [${sanitizeText(item.full_name)}](${url})

${sanitize(item.description)}
${sanitizeText(item.description)}

[${encodeURI(item.html_url)}](${encodeURI(item.html_url)})
[${url}](${url})
`;

return buffer + plugin;
Expand All @@ -49,25 +77,35 @@ ${pluginsContent}
console.log("Plugin list generated successfully.");
}

const endpoint =
"https://api.github.com/search/repositories?q=topic%3Astellar-cli-plugin+fork%3Afalse+archived%3Afalse&per_page=100&sort=stars&order=desc";

https
.get(
endpoint,
{ headers: { "user-agent": "https://github.com/stellar/stellar-docs" } },
(res) => {
let data = "";

res.on("data", (chunk) => {
data += chunk;
});

res.on("end", () => {
exportMDX(JSON.parse(data));
});
},
)
.on("error", (err) => {
console.error("Error fetching plugin list:", err.message);
});
function main() {
const endpoint =
"https://api.github.com/search/repositories?q=topic%3Astellar-cli-plugin+fork%3Afalse+archived%3Afalse&per_page=100&sort=stars&order=desc";

https
.get(
endpoint,
{ headers: { "user-agent": "https://github.com/stellar/stellar-docs" } },
(res) => {
let data = "";

res.on("data", (chunk) => {
data += chunk;
});

res.on("end", () => {
exportMDX(JSON.parse(data));
});
Comment on lines +95 to +97
},
)
.on("error", (err) => {
console.error("Error fetching plugin list:", err.message);
});
}

// Only fetch/generate when run directly (`node scripts/stellar_cli_plugins.mjs`),
// so the helpers above can be imported by tests without a network call.
if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) {
main();
}

export { sanitizeText, safeUrl, exportMDX, excludePlugins };
96 changes: 96 additions & 0 deletions scripts/stellar_cli_plugins.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { test } from "node:test";
import assert from "node:assert/strict";

import { sanitizeText, safeUrl } from "./stellar_cli_plugins.mjs";

// After sanitizeText, every Markdown/JSX/HTML-significant character must be
// backslash-escaped. Strip all `\<punctuation>` escape pairs and assert that no
// active special character is left unescaped — this is the property that makes
// the output render as literal text.
function assertFullyNeutralized(out) {
const stripped = out.replace(/\\[!-/:-@[-`{-~]/g, "");
const leftover = stripped.match(/[!-/:-@[-`{-~]/g);
assert.equal(
leftover,
null,
`unescaped special characters survived: ${JSON.stringify(leftover)} in ${JSON.stringify(out)}`,
);
}

test("sanitizeText neutralizes <script> injection", () => {
const out = sanitizeText("Cool plugin! <script>alert('Website Hacked')</script>");
assertFullyNeutralized(out);
assert.ok(!/(^|[^\\])</.test(out), "no unescaped '<' remains");
});

test("sanitizeText neutralizes JSX expression braces", () => {
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,<script>alert(1)</script>"), 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);
});
Loading