diff --git a/.codacy.yaml b/.codacy.yaml index 8f7c1ea..9be08e6 100644 --- a/.codacy.yaml +++ b/.codacy.yaml @@ -3,3 +3,7 @@ # flags non-literal fs paths as false positives. exclude_paths: - "apps/vanilla/scripts/link-sitesearch.mjs" + # XSS unit tests intentionally contain PoC HTML / javascript: payloads. + - "**/__tests__/**" + - "**/*.test.ts" + - "**/*.test.tsx" diff --git a/apps/docs/src/registry/experiences/highlight-to-askai/components/highlight-to-askai.tsx b/apps/docs/src/registry/experiences/highlight-to-askai/components/highlight-to-askai.tsx index 553fff2..1cb51a8 100644 --- a/apps/docs/src/registry/experiences/highlight-to-askai/components/highlight-to-askai.tsx +++ b/apps/docs/src/registry/experiences/highlight-to-askai/components/highlight-to-askai.tsx @@ -1,4 +1,4 @@ -/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: markdown rendering, sanitized by marked */ +/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: markdown rendering sanitized via parseMarkdownToSafeHtml */ /** biome-ignore-all lint/suspicious/noArrayIndexKey: parts are stable during render */ "use client"; @@ -17,7 +17,7 @@ import { ThumbsDown, ThumbsUp, } from "lucide-react"; -import { marked } from "marked"; +import { marked, type Tokens } from "marked"; import React from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -27,6 +27,144 @@ import { useAskai, } from "@/registry/experiences/highlight-to-askai/hooks/use-askai"; +function escapeHtml(html: string): string { + return html + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function decodeUrlForSchemeCheck(value: string): string { + let current = value; + for (let i = 0; i < 3; i += 1) { + try { + const decoded = decodeURIComponent(current); + if (decoded === current) { + break; + } + current = decoded; + } catch { + break; + } + } + return current; +} + +function stripControlsAndWhitespace(value: string): string { + let result = ""; + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code > 0x20 && code !== 0x7f) { + result += value.charAt(i); + } + } + return result; +} + +function sanitizeUrl(url: string | null | undefined): string { + if (!url) { + return ""; + } + + const trimmed = url.trim(); + if (!trimmed) { + return ""; + } + + const normalized = stripControlsAndWhitespace( + decodeUrlForSchemeCheck(trimmed), + ); + if (!normalized) { + return ""; + } + + if (normalized.startsWith("//")) { + return ""; + } + + if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(normalized)) { + return trimmed; + } + + try { + const parsed = new URL(normalized); + if ( + parsed.protocol === "http:" || + parsed.protocol === "https:" || + parsed.protocol === "mailto:" + ) { + return normalized; + } + } catch { + return ""; + } + + return ""; +} + +/* eslint-disable xss/no-mixed-html -- marked renderer: interpolations escaped/sanitized */ +const markdownRenderer = new marked.Renderer(); + +markdownRenderer.code = ({ text, lang = "", escaped }: Tokens.Code): string => { + const safeLang = /^[a-zA-Z0-9_-]+$/.test(lang) ? lang : ""; + const languageClass = safeLang ? "language-" + safeLang : ""; + const safeCode = escaped ? text : escapeHtml(text); + return ( + '
' + safeCode + "
" + ); +}; + +markdownRenderer.link = ({ href, title, text }: Tokens.Link): string => { + const safeHref = escapeHtml(sanitizeUrl(href)); + const textEscaped = escapeHtml(text); + if (!safeHref) { + return textEscaped; + } + const titleAttr = title ? ' title="' + escapeHtml(title) + '"' : ""; + // href/text/title are sanitized + escaped above. + return ( + '' + + textEscaped + + "" + ); // nosemgrep +}; + +markdownRenderer.image = ({ href, title, text }: Tokens.Image): string => { + const safeHref = escapeHtml(sanitizeUrl(href)); + if (!safeHref) { + return escapeHtml(text); + } + const titleAttr = title ? ' title="' + escapeHtml(title) + '"' : ""; + // src/alt/title are sanitized + escaped above. + return ( + '' +
+    escapeHtml(text) +
+    '" + ); // nosemgrep +}; + +markdownRenderer.html = ({ text }: Tokens.HTML | Tokens.Tag): string => + escapeHtml(text); +/* eslint-enable xss/no-mixed-html */ + +function parseMarkdownToSafeHtml(content: string): string { + return marked.parse(content, { + gfm: true, + breaks: true, + renderer: markdownRenderer, + }) as string; +} + type OnAskPayload = { text: string; html: string; @@ -510,7 +648,10 @@ export function HighlightAskAI({ return

{part}

; } if (part.type === "text") { - const html = marked.parse(part.text || ""); + // eslint-disable-next-line xss/no-mixed-html -- sanitized via parseMarkdownToSafeHtml + const html = parseMarkdownToSafeHtml( + part.text || "", + ); return (
0x20 && code !== 0x7f) { + result += value.charAt(i); + } + } + return result; +} + +function sanitizeUrl(url: string | null | undefined): string { + if (!url) { + return ""; + } + + const trimmed = url.trim(); + if (!trimmed) { + return ""; + } + + const normalized = stripControlsAndWhitespace( + decodeUrlForSchemeCheck(trimmed), + ); + if (!normalized) { + return ""; + } + + if (normalized.startsWith("//")) { + return ""; + } + + if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(normalized)) { + return trimmed; + } + + try { + const parsed = new URL(normalized); + if ( + parsed.protocol === "http:" || + parsed.protocol === "https:" || + parsed.protocol === "mailto:" + ) { + return normalized; + } + } catch { + return ""; + } + + return ""; +} + // ============================================================================ // Markdown Renderer // ============================================================================ +/* eslint-disable xss/no-mixed-html -- marked renderer: interpolations escaped/sanitized */ const markdownRenderer = new marked.Renderer(); markdownRenderer.code = ({ text, lang = "", escaped }: Tokens.Code): string => { - const languageClass = lang ? `language-${lang}` : ""; + const safeLang = /^[a-zA-Z0-9_-]+$/.test(lang) ? lang : ""; + const languageClass = safeLang ? `language-${safeLang}` : ""; const safeCode = escaped ? text : escapeHtml(text); const encodedCode = encodeURIComponent(text); @@ -265,13 +335,49 @@ markdownRenderer.code = ({ text, lang = "", escaped }: Tokens.Code): string => { }; markdownRenderer.link = ({ href, title, text }: Tokens.Link): string => { - const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; - const hrefAttr = href ? escapeHtml(href) : ""; - const textContent = text || ""; + const safeHref = escapeHtml(sanitizeUrl(href)); + const textEscaped = escapeHtml(text); - return `${textContent}`; + if (!safeHref) { + return textEscaped; + } + + const titleAttr = title ? ' title="' + escapeHtml(title) + '"' : ""; + // href/text/title are sanitized + escaped above. + return ( + '" + + textEscaped + + "" + ); // nosemgrep }; +markdownRenderer.image = ({ href, title, text }: Tokens.Image): string => { + const safeHref = escapeHtml(sanitizeUrl(href)); + if (!safeHref) { + return escapeHtml(text); + } + + const titleAttr = title ? ' title="' + escapeHtml(title) + '"' : ""; + // src/alt/title are sanitized + escaped above. + return ( + '' +
+    escapeHtml(text) +
+    '" + ); // nosemgrep +}; + +markdownRenderer.html = ({ text }: Tokens.HTML | Tokens.Tag): string => + escapeHtml(text); +/* eslint-enable xss/no-mixed-html */ + // ============================================================================ // Icon Components // ============================================================================ @@ -551,7 +657,8 @@ const MemoizedMarkdown = memo(function MemoizedMarkdown({ [&_hr]:border-none [&_hr]:border-t [&_hr]:border-border [&_hr]:my-6 [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-md [&_img]:my-2 ${className}`.trim()} - // biome-ignore lint/security/noDangerouslySetInnerHtml: its alright :) + // eslint-disable-next-line xss/no-mixed-html -- sanitized marked output + // biome-ignore lint/security/noDangerouslySetInnerHtml: HTML escaped via marked renderer (html/link/image) dangerouslySetInnerHTML={{ __html: html }} /> ); diff --git a/apps/docs/src/registry/experiences/sidepanel-askai/components/sidepanel-askai.tsx b/apps/docs/src/registry/experiences/sidepanel-askai/components/sidepanel-askai.tsx index f971135..e4591b5 100644 --- a/apps/docs/src/registry/experiences/sidepanel-askai/components/sidepanel-askai.tsx +++ b/apps/docs/src/registry/experiences/sidepanel-askai/components/sidepanel-askai.tsx @@ -140,6 +140,74 @@ function escapeHtml(html: string): string { .replace(/'/g, "'"); } +function decodeUrlForSchemeCheck(value: string): string { + let current = value; + for (let i = 0; i < 3; i += 1) { + try { + const decoded = decodeURIComponent(current); + if (decoded === current) { + break; + } + current = decoded; + } catch { + break; + } + } + return current; +} + +function stripControlsAndWhitespace(value: string): string { + let result = ""; + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code > 0x20 && code !== 0x7f) { + result += value.charAt(i); + } + } + return result; +} + +function sanitizeUrl(url: string | null | undefined): string { + if (!url) { + return ""; + } + + const trimmed = url.trim(); + if (!trimmed) { + return ""; + } + + const normalized = stripControlsAndWhitespace( + decodeUrlForSchemeCheck(trimmed), + ); + if (!normalized) { + return ""; + } + + if (normalized.startsWith("//")) { + return ""; + } + + if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(normalized)) { + return trimmed; + } + + try { + const parsed = new URL(normalized); + if ( + parsed.protocol === "http:" || + parsed.protocol === "https:" || + parsed.protocol === "mailto:" + ) { + return normalized; + } + } catch { + return ""; + } + + return ""; +} + function extractLinksFromMessage(message: Message | null): ExtractedLink[] { const links: ExtractedLink[] = []; @@ -182,10 +250,10 @@ function extractLinksFromMessage(message: Message | null): ExtractedLink[] { // Parses the title and url from the found links for (const match of markdownMatches) { const title = match[1].trim(); - const url = match[2]; + const url = sanitizeUrl(match[2]); // Skip image URLs - if (imageUrls.has(url)) { + if (!url || imageUrls.has(url) || imageUrls.has(match[2])) { continue; } @@ -200,10 +268,10 @@ function extractLinksFromMessage(message: Message | null): ExtractedLink[] { for (const match of plainUrls) { // Strip any extra punctuation - const cleanUrl = match[0].replace(/[.,;:!?]+$/, ""); + const cleanUrl = sanitizeUrl(match[0].replace(/[.,;:!?]+$/, "")); // Skip image URLs - if (imageUrls.has(cleanUrl)) { + if (!cleanUrl || imageUrls.has(cleanUrl)) { continue; } @@ -221,10 +289,12 @@ function extractLinksFromMessage(message: Message | null): ExtractedLink[] { // Markdown Renderer // ============================================================================ +/* eslint-disable xss/no-mixed-html -- marked renderer: interpolations escaped/sanitized */ const markdownRenderer = new marked.Renderer(); markdownRenderer.code = ({ text, lang = "", escaped }: Tokens.Code): string => { - const languageClass = lang ? `language-${lang}` : ""; + const safeLang = /^[a-zA-Z0-9_-]+$/.test(lang) ? lang : ""; + const languageClass = safeLang ? `language-${safeLang}` : ""; const safeCode = escaped ? text : escapeHtml(text); const encodedCode = encodeURIComponent(text); @@ -253,13 +323,49 @@ markdownRenderer.code = ({ text, lang = "", escaped }: Tokens.Code): string => { }; markdownRenderer.link = ({ href, title, text }: Tokens.Link): string => { - const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; - const hrefAttr = href ? escapeHtml(href) : ""; - const textContent = text || ""; + const safeHref = escapeHtml(sanitizeUrl(href)); + const textEscaped = escapeHtml(text); - return `${textContent}`; + if (!safeHref) { + return textEscaped; + } + + const titleAttr = title ? ' title="' + escapeHtml(title) + '"' : ""; + // href/text/title are sanitized + escaped above. + return ( + '" + + textEscaped + + "" + ); // nosemgrep }; +markdownRenderer.image = ({ href, title, text }: Tokens.Image): string => { + const safeHref = escapeHtml(sanitizeUrl(href)); + if (!safeHref) { + return escapeHtml(text); + } + + const titleAttr = title ? ' title="' + escapeHtml(title) + '"' : ""; + // src/alt/title are sanitized + escaped above. + return ( + '' +
+    escapeHtml(text) +
+    '" + ); // nosemgrep +}; + +markdownRenderer.html = ({ text }: Tokens.HTML | Tokens.Tag): string => + escapeHtml(text); +/* eslint-enable xss/no-mixed-html */ + // ============================================================================ // Icon Components // ============================================================================ @@ -464,7 +570,8 @@ const MemoizedMarkdown = memo(function MemoizedMarkdown({ [&_hr]:border-none [&_hr]:border-t [&_hr]:border-border [&_hr]:my-6 [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-md [&_img]:my-2 ${className}`.trim()} - // biome-ignore lint/security/noDangerouslySetInnerHtml: its alright :) + // eslint-disable-next-line xss/no-mixed-html -- sanitized marked output + // biome-ignore lint/security/noDangerouslySetInnerHtml: HTML escaped via marked renderer (html/link/image) dangerouslySetInnerHTML={{ __html: html }} /> ); diff --git a/packages/standalone/package.json b/packages/standalone/package.json index c224405..d160808 100644 --- a/packages/standalone/package.json +++ b/packages/standalone/package.json @@ -18,6 +18,7 @@ "clean": "rm -rf dist", "dev": "rolldown -w -c rolldown.config.js", "prepublishOnly": "bun run clean && bun run build", + "test": "bun test src", "version": "bun pm version" }, "dependencies": { diff --git a/packages/standalone/src/components/search-askai/markdown.tsx b/packages/standalone/src/components/search-askai/markdown.tsx index 859c60e..b225fa1 100644 --- a/packages/standalone/src/components/search-askai/markdown.tsx +++ b/packages/standalone/src/components/search-askai/markdown.tsx @@ -1,104 +1,20 @@ -import { marked, type Tokens } from "marked"; import { memo, useEffect, useMemo, useRef } from "react"; +import { parseMarkdownToSafeHtml } from "./utils/markdown"; interface MemoizedMarkdownProps { children: string; className?: string; } -/** Replace unpaired UTF-16 surrogates (common in crawled index text). */ -function replaceUnpairedSurrogates(value: string): string { - return value.replace( - /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -// Create a custom renderer -const renderer = new marked.Renderer(); - -// Custom code block renderer with copy functionality -renderer.code = ({ text, lang = "", escaped }: Tokens.Code): string => { - const languageClass = lang ? `language-${lang}` : ""; - const safeCode = escaped ? text : escapeHtml(text); - const encodedCode = safeEncodeURIComponent(text); - - const copyIconSvg = ` - - - - - `; - - const checkIconSvg = ` - - - - `; - - return ` -
- -
${safeCode}
-
- `; -}; - -// Ensure markdown links open in new tab with security attributes -renderer.link = ({ href, title, text }: Tokens.Link): string => { - const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; - const hrefAttr = href ? escapeHtml(href) : ""; - const textContent = text || ""; - - return `${textContent}`; -}; - export const MemoizedMarkdown = memo(function MemoizedMarkdown({ children, className = "", }: MemoizedMarkdownProps) { const containerRef = useRef(null); - const html = useMemo(() => { - const source = toMarkdownString(children); - try { - return marked(source, { - renderer, - breaks: true, - gfm: true, - }); - } catch (error) { - console.error("Error parsing markdown:", error); - return escapeHtml(source); - } - }, [children]); + // HTML is produced by parseMarkdownToSafeHtml (escapes raw HTML, sanitizes URLs). + // eslint-disable-next-line xss/no-mixed-html -- sanitized via parseMarkdownToSafeHtml + const html = useMemo(() => parseMarkdownToSafeHtml(children), [children]); // Handle copy button clicks // biome-ignore lint/correctness/useExhaustiveDependencies: expected @@ -147,7 +63,8 @@ export const MemoizedMarkdown = memo(function MemoizedMarkdown({
); diff --git a/packages/standalone/src/components/search-askai/utils/__tests__/markdown.test.ts b/packages/standalone/src/components/search-askai/utils/__tests__/markdown.test.ts new file mode 100644 index 0000000..a8cc7d7 --- /dev/null +++ b/packages/standalone/src/components/search-askai/utils/__tests__/markdown.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "bun:test"; +import { parseMarkdownToSafeHtml } from "../markdown"; + +describe("parseMarkdownToSafeHtml", () => { + it("escapes raw HTML so event handlers cannot run", () => { + const html = parseMarkdownToSafeHtml(""); + expect(html).not.toMatch(/ { + const html = parseMarkdownToSafeHtml("a b"); + expect(html).not.toContain("")).toBe(""); + expect(sanitizeUrl("vbscript:msgbox(1)")).toBe(""); + expect(sanitizeUrl("//evil.example/path")).toBe(""); + }); + + it("blocks schemes broken up by whitespace or control characters", () => { + expect(sanitizeUrl(`java\tscript${":"}alert(1)`)).toBe(""); + expect(sanitizeUrl(`java\nscript${":"}alert(1)`)).toBe(""); + expect(sanitizeUrl(`java\rscript${":"}alert(1)`)).toBe(""); + expect(sanitizeUrl(`java script${":"}alert(1)`)).toBe(""); + expect(sanitizeUrl(`java\u0000script${":"}alert(1)`)).toBe(""); + }); + + it("blocks percent-encoded schemes", () => { + expect(sanitizeUrl("%6Aavascript:alert(1)")).toBe(""); + expect(sanitizeUrl("%6aavascript:alert(1)")).toBe(""); + expect(sanitizeUrl("java%09script:alert(1)")).toBe(""); + expect(sanitizeUrl("java%0ascript:alert(1)")).toBe(""); + expect(sanitizeUrl("%256Aavascript:alert(1)")).toBe(""); + }); +}); diff --git a/packages/standalone/src/components/search-askai/utils/markdown.ts b/packages/standalone/src/components/search-askai/utils/markdown.ts new file mode 100644 index 0000000..97555a2 --- /dev/null +++ b/packages/standalone/src/components/search-askai/utils/markdown.ts @@ -0,0 +1,117 @@ +import { marked, type Tokens } from "marked"; +import { escapeHtml, sanitizeUrl } from "./sanitize"; + +/** Replace unpaired UTF-16 surrogates (common in crawled index text). */ +function replaceUnpairedSurrogates(value: string): string { + return value.replace( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const safeLang = /^[a-zA-Z0-9_-]+$/.test(lang) ? lang : ""; + const languageClass = safeLang ? `language-${safeLang}` : ""; + const safeCode = escaped ? text : escapeHtml(text); + const encodedCode = safeEncodeURIComponent(text); + + const copyIconAsHtml = [ + '', + '', + '', + "", + ].join(""); + + const checkIconAsHtml = [ + '', + '', + "", + ].join(""); + + return [ + '
', + '", + '
' + safeCode + "
", + "
", + ].join(""); +}; + +renderer.link = ({ href, title, text }: Tokens.Link): string => { + const safeHref = escapeHtml(sanitizeUrl(href)); + const textEscaped = escapeHtml(text); + + if (!safeHref) { + return textEscaped; + } + + const titleAttr = title ? ' title="' + escapeHtml(title) + '"' : ""; + return ( + '" + + textEscaped + + "" + ); +}; + +renderer.image = ({ href, title, text }: Tokens.Image): string => { + const safeHref = escapeHtml(sanitizeUrl(href)); + if (!safeHref) { + return escapeHtml(text); + } + + const titleAttr = title ? ' title="' + escapeHtml(title) + '"' : ""; + return ( + '' +
+    escapeHtml(text) +
+    '" + ); +}; + +renderer.html = ({ text }: Tokens.HTML | Tokens.Tag): string => + escapeHtml(text); +/* eslint-enable xss/no-mixed-html */ + +/** Parses markdown into HTML safe for `dangerouslySetInnerHTML`. */ +export function parseMarkdownToSafeHtml(content: string): string { + const source = toMarkdownString(content); + try { + return marked.parse(source, { + gfm: true, + breaks: true, + renderer, + }) as string; + } catch (error) { + console.error("Error parsing markdown:", error); + return escapeHtml(source); + } +} diff --git a/packages/standalone/src/components/search-askai/utils/sanitize.ts b/packages/standalone/src/components/search-askai/utils/sanitize.ts new file mode 100644 index 0000000..6402935 --- /dev/null +++ b/packages/standalone/src/components/search-askai/utils/sanitize.ts @@ -0,0 +1,83 @@ +/** Escapes HTML special characters for safe interpolation into HTML strings. */ +export function escapeHtml(unsafe: string): string { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function decodeUrlForSchemeCheck(value: string): string { + let current = value; + for (let i = 0; i < 3; i += 1) { + try { + const decoded = decodeURIComponent(current); + if (decoded === current) { + break; + } + current = decoded; + } catch { + break; + } + } + return current; +} + +function stripControlsAndWhitespace(value: string): string { + // Strip ASCII controls + whitespace so scheme checks cannot be bypassed via + // `java\tscript:` / `java script:` style obfuscation. + let result = ""; + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + if (code > 0x20 && code !== 0x7f) { + result += value.charAt(i); + } + } + return result; +} + +/** + * Returns a URL safe for href/src, or '' if unsafe. + * Does not HTML-escape — callers building HTML strings must escape separately. + */ +export function sanitizeUrl(url: string | null | undefined): string { + if (!url) { + return ""; + } + + const trimmed = url.trim(); + if (!trimmed) { + return ""; + } + + const normalized = stripControlsAndWhitespace( + decodeUrlForSchemeCheck(trimmed), + ); + if (!normalized) { + return ""; + } + + if (normalized.startsWith("//")) { + return ""; + } + + if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(normalized)) { + return trimmed; + } + + try { + const parsed = new URL(normalized); + if ( + parsed.protocol === "http:" || + parsed.protocol === "https:" || + parsed.protocol === "mailto:" + ) { + return normalized; + } + } catch { + return ""; + } + + return ""; +}