fix: sanitize Ask AI markdown HTML to prevent XSS (#2929)
This commit is contained in:
parent
96476dd6f1
commit
681cbfec03
7 changed files with 257 additions and 62 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -125,6 +125,21 @@ describe('utils', () => {
|
|||
|
||||
expect(extractLinksFromMessage(message)).toEqual([{ 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,54 @@
|
|||
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,57 @@
|
|||
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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
readAgentStudioJsonStringField,
|
||||
resolveAgentStudioPromptBlocking,
|
||||
} from './askAiBlockingMatchers';
|
||||
import { sanitizeUserInput } from './sanitize';
|
||||
import { sanitizeUrl, sanitizeUserInput } from './sanitize';
|
||||
|
||||
type ExtractedLink = {
|
||||
url: string;
|
||||
|
|
@ -49,9 +49,9 @@ export function extractLinksFromMessage(message: AIMessage | null): ExtractedLin
|
|||
// 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 });
|
||||
}
|
||||
|
|
@ -62,9 +62,9 @@ export function extractLinksFromMessage(message: AIMessage | null): ExtractedLin
|
|||
|
||||
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 });
|
||||
}
|
||||
|
|
|
|||
56
packages/docsearch-react/src/utils/markdown.ts
Normal file
56
packages/docsearch-react/src/utils/markdown.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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,74 @@ 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