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 (
+ '
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 (
+ '

"
+ ); // 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 (
+ '

"
+ ); // 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 (
+ '
"
+ );
+};
+
+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 "";
+}