diff --git a/packages/web/src/components/share/content-markdown.test.ts b/packages/web/src/components/share/content-markdown.test.ts new file mode 100644 index 000000000000..3262e9feeec0 --- /dev/null +++ b/packages/web/src/components/share/content-markdown.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test" + +/** + * CWE-79: Cross-Site Scripting (XSS) + * File: packages/web/src/components/share/content-markdown.tsx + * + * The link renderer interpolated href/title directly into HTML without escaping. + * Combined with innerHTML rendering, this allows XSS via crafted markdown links. + */ + +function escapeHtml(str: string): string { + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'") +} + +const SAFE_URL_PATTERN = /^(?:https?|mailto|tel):/i + +function renderLink(href: string, title: string | null, text: string): string { + if (!SAFE_URL_PATTERN.test(href)) { + return `${text}` + } + const safeHref = escapeHtml(href) + const titleAttr = title ? ` title="${escapeHtml(title)}"` : "" + return `${text}` +} + +describe("CWE-79: XSS in content-markdown.tsx link renderer", () => { + describe("escapeHtml", () => { + test("should escape all HTML special characters", () => { + expect(escapeHtml('", null, "click") + expect(result).toBe("click") + }) + + test("should allow https: URLs", () => { + const result = renderLink("https://example.com", null, "click") + expect(result).toContain('href="https://example.com"') + }) + + test("should allow mailto: URLs", () => { + const result = renderLink("mailto:user@example.com", null, "email") + expect(result).toContain('href="mailto:user@example.com"') + }) + }) + + describe("attribute escaping", () => { + test("should escape quotes in href to prevent attribute breakout", () => { + const result = renderLink('https://example.com" onmouseover="alert(1)', null, "click") + expect(result).toContain(""") + // The quotes are escaped, so the browser won't parse onmouseover as an attribute + expect(result).not.toContain(' onmouseover="alert') + }) + + test("should escape quotes in title", () => { + const result = renderLink("https://example.com", '" onmouseover="alert(1)', "click") + expect(result).toContain(""") + expect(result).not.toContain(' onmouseover="alert') + }) + + test("should escape ampersands in href", () => { + const result = renderLink("https://example.com?a=1&b=2", null, "click") + expect(result).toContain("href=\"https://example.com?a=1&b=2\"") + }) + }) + + describe("normal links", () => { + test("should render correctly with all attributes", () => { + const result = renderLink("https://opencode.ai", "Official", "OpenCode") + expect(result).toContain('href="https://opencode.ai"') + expect(result).toContain('title="Official"') + expect(result).toContain('target="_blank"') + expect(result).toContain('rel="noopener noreferrer"') + expect(result).toContain("OpenCode") + }) + }) +}) diff --git a/packages/web/src/components/share/content-markdown.tsx b/packages/web/src/components/share/content-markdown.tsx index 10a06bf5ed80..0f07c17f4915 100644 --- a/packages/web/src/components/share/content-markdown.tsx +++ b/packages/web/src/components/share/content-markdown.tsx @@ -6,12 +6,22 @@ import { CopyButton } from "./copy-button" import { createResource, createSignal } from "solid-js" import style from "./content-markdown.module.css" +function escapeHtml(str: string): string { + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'") +} + +const SAFE_URL_PATTERN = /^(?:https?|mailto|tel):/i + const markedWithShiki = marked.use( { renderer: { link({ href, title, text }) { - const titleAttr = title ? ` title="${title}"` : "" - return `${text}` + if (!SAFE_URL_PATTERN.test(href)) { + return `${text}` + } + const safeHref = escapeHtml(href) + const titleAttr = title ? ` title="${escapeHtml(title)}"` : "" + return `${text}` }, }, },