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:
parent
312be0f663
commit
9a1b3e4c6c
8 changed files with 281 additions and 62 deletions
5
.changeset/safe-answers-smile.md
Normal file
5
.changeset/safe-answers-smile.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@docsearch/react": patch
|
||||
---
|
||||
|
||||
Sanitize Ask AI Markdown HTML and URLs before rendering to prevent XSS.
|
||||
|
|
@ -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, '"')
|
||||
.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 = `<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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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('<img');
|
||||
// Attribute text may remain, but must not form a real HTML tag
|
||||
expect(html).toBe('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
it('escapes inline script tags', () => {
|
||||
const html = parseMarkdownToSafeHtml('a <script>alert(1)</script> b');
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
|
||||
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(`)`);
|
||||
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('<script>');
|
||||
});
|
||||
|
||||
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-">');
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
'<img src="x" onerror='alert(1)'>'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeUserInput', () => {
|
||||
it('strips tags then escapes remaining text', () => {
|
||||
expect(sanitizeUserInput('<b>hello</b> & world')).toBe('hello & 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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
57
packages/docsearch-react/src/utils/markdown.ts
Normal file
57
packages/docsearch-react/src/utils/markdown.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 '';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue