diff --git a/.changeset/safe-answers-smile.md b/.changeset/safe-answers-smile.md
new file mode 100644
index 00000000..cfe147fc
--- /dev/null
+++ b/.changeset/safe-answers-smile.md
@@ -0,0 +1,5 @@
+---
+"@docsearch/react": patch
+---
+
+Sanitize Ask AI Markdown HTML and URLs before rendering to prevent XSS.
diff --git a/packages/docsearch-react/src/MemoizedMarkdown.tsx b/packages/docsearch-react/src/MemoizedMarkdown.tsx
index 7931e9ad..76b1f11c 100644
--- a/packages/docsearch-react/src/MemoizedMarkdown.tsx
+++ b/packages/docsearch-react/src/MemoizedMarkdown.tsx
@@ -1,43 +1,6 @@
-import { marked, type Tokens } from 'marked';
import React, { memo, useMemo, useEffect, useRef } from 'react';
-// escape html special chars for safe insertion into pre/code blocks
-function escapeHtml(html: string): string {
- return html
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
-}
-
-const renderer = new marked.Renderer();
-
-renderer.code = ({ text, lang = '', escaped }: Tokens.Code): string => {
- const languageClass = lang ? `language-${lang}` : '';
- const safeCode = escaped ? text : escapeHtml(text);
- const encodedCode = encodeURIComponent(text);
-
- // svg icons (copy & check)
- const copyIconSvg = ``;
-
- const checkIconSvg = ``;
-
- return `
-
-
-
${safeCode}
-
- `;
-};
-
-// ensure all markdown links open in a new tab with rel noopener for security
-renderer.link = ({ href, title, text }: Tokens.Link): string => {
- const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
- const hrefAttr = href ? escapeHtml(href) : '';
- const textEscaped = escapeHtml(text);
- return `${textEscaped}`;
-};
+import { parseMarkdownToSafeHtml } from './utils/markdown';
export const MemoizedMarkdown = memo(
({
@@ -51,15 +14,7 @@ export const MemoizedMarkdown = memo(
copyButtonCopiedText: string;
isStreaming: boolean;
}) => {
- const html = useMemo(
- () =>
- marked.parse(content, {
- gfm: true,
- breaks: true,
- renderer,
- }),
- [content]
- );
+ const html = useMemo(() => parseMarkdownToSafeHtml(content), [content]);
// container ref to scope dom queries and events
const containerRef = useRef(null);
diff --git a/packages/docsearch-react/src/__tests__/utils.test.ts b/packages/docsearch-react/src/__tests__/utils.test.ts
index 11d14932..530dfd37 100644
--- a/packages/docsearch-react/src/__tests__/utils.test.ts
+++ b/packages/docsearch-react/src/__tests__/utils.test.ts
@@ -248,6 +248,23 @@ describe('utils', () => {
{ url: 'https://docsearch.algolia.com', title: 'DocSearch' },
]);
});
+
+ it('drops unsafe markdown link schemes', () => {
+ const message: AIMessage = {
+ id: '123',
+ role: 'assistant',
+ parts: [
+ {
+ type: 'text',
+ text: `See [safe](https://docsearch.algolia.com) and [bad](javascript${':'}alert(1))`,
+ },
+ ],
+ };
+
+ expect(extractLinksFromMessage(message)).toEqual([
+ { url: 'https://docsearch.algolia.com', title: 'safe' },
+ ]);
+ });
});
describe('createObjectStorage', () => {
diff --git a/packages/docsearch-react/src/utils/__tests__/markdown.test.ts b/packages/docsearch-react/src/utils/__tests__/markdown.test.ts
new file mode 100644
index 00000000..0eb238e7
--- /dev/null
+++ b/packages/docsearch-react/src/utils/__tests__/markdown.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest';
+
+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/docsearch-react/src/utils/ai.ts b/packages/docsearch-react/src/utils/ai.ts
index 456c49be..21d377f1 100644
--- a/packages/docsearch-react/src/utils/ai.ts
+++ b/packages/docsearch-react/src/utils/ai.ts
@@ -13,7 +13,7 @@ import type {
ToolCalls,
} from '../types/AskiAi';
-import { sanitizeUserInput } from './sanitize';
+import { sanitizeUrl, sanitizeUserInput } from './sanitize';
export interface ExtractedLink {
url: string;
@@ -56,9 +56,9 @@ export function extractLinksFromMessage(
// 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]);
- if (!seen.has(url)) {
+ if (url && !seen.has(url)) {
seen.add(url);
links.push({ url, title: title || undefined });
}
@@ -69,9 +69,9 @@ export function extractLinksFromMessage(
for (const match of plainUrls) {
// Strip any extra punctuation
- const cleanUrl = match[0].replace(/[.,;:!?]+$/, '');
+ const cleanUrl = sanitizeUrl(match[0].replace(/[.,;:!?]+$/, ''));
- if (!seen.has(cleanUrl)) {
+ if (cleanUrl && !seen.has(cleanUrl)) {
seen.add(cleanUrl);
links.push({ url: cleanUrl });
}
diff --git a/packages/docsearch-react/src/utils/markdown.ts b/packages/docsearch-react/src/utils/markdown.ts
new file mode 100644
index 00000000..5bc25966
--- /dev/null
+++ b/packages/docsearch-react/src/utils/markdown.ts
@@ -0,0 +1,57 @@
+import { marked, type Tokens } from 'marked';
+
+import { escapeHtml, sanitizeUrl } from './sanitize';
+
+const renderer = new marked.Renderer();
+
+renderer.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);
+ const encodedCode = encodeURIComponent(text);
+
+ const copyIconSvg = ``;
+
+ const checkIconSvg = ``;
+
+ return `
+
+
+
${safeCode}
+
+ `;
+};
+
+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);
+
+/** Parses markdown into HTML safe for `dangerouslySetInnerHTML`. */
+export function parseMarkdownToSafeHtml(content: string): string {
+ return marked.parse(content, {
+ gfm: true,
+ breaks: true,
+ renderer,
+ }) as string;
+}
diff --git a/packages/docsearch-react/src/utils/sanitize.ts b/packages/docsearch-react/src/utils/sanitize.ts
index 1a82d949..7213793e 100644
--- a/packages/docsearch-react/src/utils/sanitize.ts
+++ b/packages/docsearch-react/src/utils/sanitize.ts
@@ -1,7 +1,4 @@
-/**
- * Escapes HTML special characters to prevent XSS attacks. This should be used
- * for any user-provided content that will be displayed.
- */
+/** Escapes HTML special characters for safe interpolation into HTML strings. */
export function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&')
@@ -11,13 +8,80 @@ export function escapeHtml(unsafe: string): string {
.replace(/'/g, ''');
}
-/**
- * Sanitizes user input to prevent XSS attacks. Removes any HTML tags and
- * escapes special characters.
- */
+/** Strips HTML tags and escapes the remainder for plain-text React children. */
export function sanitizeUserInput(input: string): string {
- // First, remove any HTML tags
const withoutTags = input.replace(/<[^>]*>/g, '');
- // Then escape any remaining special characters
return escapeHtml(withoutTags);
}
+
+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[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 '';
+}