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 `${escapeHtml(text)}`; +}; + +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 ''; +}