1
0
Fork 0

fix: sanitize Ask AI markdown HTML to prevent XSS (#2929)

This commit is contained in:
Vasco Bettencourt 2026-07-22 21:53:41 +01:00 committed by GitHub
parent 96476dd6f1
commit 681cbfec03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 257 additions and 62 deletions

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, '&')
.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

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

View file

@ -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('&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,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(
'&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

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

View 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;
}

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,74 @@ 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 '';
}