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}`
},
},
},