1
0
Fork 0

fix(askai): sanitize markdown HTML in v5 (#2954)

Backport of #2929.\n\nOriginal commit: 681cbfec03

Co-authored-by: Vasco Bettencourt <32492444+vascobettencourt@users.noreply.github.com>
This commit is contained in:
Paul Jankowski 2026-08-05 10:18:38 -04:00 committed by GitHub
parent 312be0f663
commit 9a1b3e4c6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 281 additions and 62 deletions

View file

@ -0,0 +1,5 @@
---
"@docsearch/react": patch
---
Sanitize Ask AI Markdown HTML and URLs before rendering to prevent XSS.

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 = `<svg class="DocSearch-CodeSnippet-CopyIcon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg>`;
const checkIconSvg = `<svg class="DocSearch-CodeSnippet-CheckIcon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg>`;
return `
<div class="DocSearch-CodeSnippet">
<button class="DocSearch-CodeSnippet-CopyButton" data-code="${encodedCode}" aria-label="copy code">${copyIconSvg}${checkIconSvg}<span class="DocSearch-CodeSnippet-CopyButton-Label"></span></button>
<pre><code class="${languageClass}">${safeCode}</code></pre>
</div>
`;
};
// 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 `<a href="${hrefAttr}"${titleAttr} target="_blank" rel="noopener noreferrer">${textEscaped}</a>`;
};
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<HTMLDivElement>(null);

View file

@ -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', () => {

View file

@ -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('<img src=x onerror=alert(1)>');
expect(html).not.toMatch(/<img\b/i);
expect(html).toContain('&lt;img');
// Attribute text may remain, but must not form a real HTML tag
expect(html).toBe('&lt;img src=x onerror=alert(1)&gt;');
});
it('escapes inline script tags', () => {
const html = parseMarkdownToSafeHtml('a <script>alert(1)</script> b');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});
it('does not emit javascript: links', () => {
const html = parseMarkdownToSafeHtml(`[click](javascript${':'}alert(1))`);
expect(html).not.toContain(`javascript${':'}`);
expect(html).not.toContain('<a ');
expect(html).toContain('click');
});
it('does not emit javascript: images', () => {
const html = parseMarkdownToSafeHtml(`![x](javascript${':'}alert(1))`);
expect(html).not.toContain(`javascript${':'}`);
expect(html).not.toContain('<img');
});
it('keeps safe markdown links and formatting', () => {
const html = parseMarkdownToSafeHtml(
'See [DocSearch](https://docsearch.algolia.com) and **bold**'
);
expect(html).toContain('href="https://docsearch.algolia.com"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
expect(html).toContain('<strong>bold</strong>');
});
it('still renders fenced code blocks with escaped content', () => {
const html = parseMarkdownToSafeHtml('```js\nconst x = "<script>"\n```');
expect(html).toContain('DocSearch-CodeSnippet');
expect(html).toContain('language-js');
expect(html).toContain('&lt;script&gt;');
});
it('does not allow HTML breakout via fenced-code lang', () => {
const html = parseMarkdownToSafeHtml(
'```"><img src=x onerror=alert(1)>\nx\n```'
);
expect(html).not.toMatch(/<img\b/i);
expect(html).not.toContain('onerror=alert');
expect(html).not.toContain('language-">');
});
});

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { escapeHtml, sanitizeUrl, sanitizeUserInput } from '../sanitize';
describe('escapeHtml', () => {
it('escapes HTML special characters', () => {
expect(escapeHtml(`<img src="x" onerror='alert(1)'>`)).toBe(
'&lt;img src=&quot;x&quot; onerror=&#039;alert(1)&#039;&gt;'
);
});
});
describe('sanitizeUserInput', () => {
it('strips tags then escapes remaining text', () => {
expect(sanitizeUserInput('<b>hello</b> & world')).toBe('hello &amp; world');
});
});
describe('sanitizeUrl', () => {
it('allows http(s) and mailto URLs', () => {
expect(sanitizeUrl('https://docsearch.algolia.com')).toBe(
'https://docsearch.algolia.com'
);
expect(sanitizeUrl('http://example.com/path')).toBe(
'http://example.com/path'
);
expect(sanitizeUrl('mailto:docs@example.com')).toBe(
'mailto:docs@example.com'
);
});
it('allows relative paths and fragments', () => {
expect(sanitizeUrl('/docs/api')).toBe('/docs/api');
expect(sanitizeUrl('#section')).toBe('#section');
expect(sanitizeUrl('?q=1')).toBe('?q=1');
expect(sanitizeUrl('./relative')).toBe('./relative');
});
it('blocks javascript and other unsafe schemes', () => {
const jsAlert = `javascript${':'}alert(1)`;
expect(sanitizeUrl(jsAlert)).toBe('');
expect(sanitizeUrl(`JAVASCRIPT${':'}alert(1)`)).toBe('');
expect(sanitizeUrl('data:text/html,<script>alert(1)</script>')).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('');
});
});

View file

@ -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 });
}

View file

@ -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 = `<svg class="DocSearch-CodeSnippet-CopyIcon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg>`;
const checkIconSvg = `<svg class="DocSearch-CodeSnippet-CheckIcon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg>`;
return `
<div class="DocSearch-CodeSnippet">
<button class="DocSearch-CodeSnippet-CopyButton" data-code="${encodedCode}" aria-label="copy code">${copyIconSvg}${checkIconSvg}<span class="DocSearch-CodeSnippet-CopyButton-Label"></span></button>
<pre><code class="${languageClass}">${safeCode}</code></pre>
</div>
`;
};
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 `<a href="${safeHref}"${titleAttr} target="_blank" rel="noopener noreferrer">${textEscaped}</a>`;
};
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 `<img src="${safeHref}" alt="${escapeHtml(text)}"${titleAttr} />`;
};
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;
}

View file

@ -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, '&amp;')
@ -11,13 +8,80 @@ export function escapeHtml(unsafe: string): string {
.replace(/'/g, '&#039;');
}
/**
* 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 '';
}