Blog
+
Changelog
Docs {
+ const entry = parseChangelog(markdown)[0]?.entries[0];
+ if (!entry) throw new Error("expected one parsed entry");
+ return entry.body;
+};
+
+const release = (item: string) => `# executor\n\n## 9.9.9\n\n### Patch Changes\n\n- ${item}\n`;
+
+describe("renderChangelogHtml", () => {
+ it("neutralizes raw HTML embedded in a changeset", () => {
+ const html = renderChangelogHtml(
+ bodyOf(
+ release(
+ 'Fix the thing.
Done.',
+ ),
+ ),
+ );
+
+ expect(html).not.toContain("
![]()
Fix the thing. Done.");
+ });
+
+ it("strips event handlers from tags it otherwise allows", () => {
+ expect(renderChangelogHtml('
Text
').trim()).toBe("
Text
");
+ });
+
+ it("drops non-https link targets while keeping the link text", () => {
+ const html = renderChangelogHtml(
+ bodyOf(
+ release(
+ "See [docs](javascript:alert(1)) and [more](https://executor.sh/docs) and [http](http://example.com).",
+ ),
+ ),
+ );
+
+ expect(html).not.toContain("javascript:");
+ expect(html).not.toContain("http://example.com");
+ expect(html).toContain('
docs");
+ });
+
+ it("keeps ordinary changelog prose intact", () => {
+ const html = renderChangelogHtml(
+ "**Highlights**\n\n- One thing\n- Another\n\n### Details\n\nUse `
/mcp` for the path.",
+ );
+
+ expect(html).toContain("Highlights");
+ expect(html).toContain("One thing");
+ expect(html).toContain("Details
");
+ expect(html).toContain("<origin>/mcp");
+ });
+});
+
+describe("changelogBodyToText", () => {
+ it("publishes no markup for a hostile changeset", () => {
+ const text = changelogBodyToText(
+ bodyOf(release('Fix it.
Done.')),
+ );
+
+ expect(text).not.toContain("
{
+ // A code span is escaped by the renderer, so decoding `<` here would put
+ // a working `
` back into a payload that is meant to carry no markup.
+ const text = changelogBodyToText("Never write `
` in a changeset.");
+
+ expect(text).not.toContain("
{
+ expect(changelogBodyToText("**Integrations & auth** for [`login`](https://executor.sh).")).toBe(
+ "Integrations & auth for login.",
+ );
+ });
+});
diff --git a/apps/marketing/src/lib/changelog-html.ts b/apps/marketing/src/lib/changelog-html.ts
new file mode 100644
index 0000000000..1da8f677af
--- /dev/null
+++ b/apps/marketing/src/lib/changelog-html.ts
@@ -0,0 +1,78 @@
+// Rendering for changelog bodies.
+//
+// CHANGELOG.md is generated from changesets, and a changeset is written by
+// whoever opened the pull request. Its text is therefore untrusted input that
+// happens to live in the repository, and markdown allows both raw HTML and
+// `javascript:` links. Every path that turns a body into HTML goes through
+// `renderChangelogHtml`, which parses the markdown and then re-serializes it
+// through a fixed allowlist, so anything outside that list cannot survive —
+// no `