mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-02 13:57:19 +02:00
📑 fix: Sanitize Markdown Artifacts (#12249)
* 🛡️ fix: Sanitize markdown artifact rendering to prevent stored XSS
Replace marked-react with react-markdown + remark-gfm for artifact
markdown preview. react-markdown's skipHtml strips raw HTML tags,
and a urlTransform guard blocks javascript: and data: protocol links.
* fix: Update useArtifactProps test to expect react-markdown dependencies
* fix: Harden markdown artifact sanitization
- Convert isSafeUrl from denylist to allowlist (http, https, mailto, tel
plus relative/anchor URLs); unknown protocols are now fail-closed
- Add remark-breaks to restore single-newline-to-<br> behavior that was
silently dropped when replacing marked-react
- Export isSafeUrl from the host module and add 16 direct unit tests
covering allowed protocols, blocked schemes (javascript, data, blob,
vbscript, file, custom), edge cases (empty, whitespace, mixed case)
- Hoist remarkPlugins to a module-level constant to avoid per-render
array allocation in the generated Sandpack component
- Fix import order in generated template (shortest to longest per
AGENTS.md) and remove pre-existing trailing whitespace
* fix: Return null for blocked URLs, add sync-guard comments and test
- urlTransform returns null (not '') for blocked URLs so react-markdown
omits the href/src attribute entirely instead of producing <a href="">
- Hoist urlTransform to module-level constant alongside remarkPlugins
- Add JSDoc sync-guard comments tying the exported isSafeUrl to its
template-string mirror, so future maintainers know to update both
- Add synchronization test asserting the embedded isSafeUrl contains the
same allowlist set, URL parsing, and relative-path checks as the export
This commit is contained in:
parent
bcf45519bd
commit
f9927f0168
4 changed files with 148 additions and 13 deletions
|
|
@ -1,4 +1,72 @@
|
|||
import { getMarkdownFiles } from '../markdown';
|
||||
import { isSafeUrl, getMarkdownFiles } from '../markdown';
|
||||
|
||||
describe('isSafeUrl', () => {
|
||||
it('allows https URLs', () => {
|
||||
expect(isSafeUrl('https://example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows http URLs', () => {
|
||||
expect(isSafeUrl('http://example.com/path')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows mailto links', () => {
|
||||
expect(isSafeUrl('mailto:user@example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows tel links', () => {
|
||||
expect(isSafeUrl('tel:+1234567890')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows relative paths', () => {
|
||||
expect(isSafeUrl('/path/to/page')).toBe(true);
|
||||
expect(isSafeUrl('./relative')).toBe(true);
|
||||
expect(isSafeUrl('../parent')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows anchor links', () => {
|
||||
expect(isSafeUrl('#section')).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks javascript: protocol', () => {
|
||||
expect(isSafeUrl('javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks javascript: with leading whitespace', () => {
|
||||
expect(isSafeUrl(' javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks javascript: with mixed case', () => {
|
||||
expect(isSafeUrl('JavaScript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks data: protocol', () => {
|
||||
expect(isSafeUrl('data:text/html,<b>x</b>')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks blob: protocol', () => {
|
||||
expect(isSafeUrl('blob:http://example.com/uuid')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks vbscript: protocol', () => {
|
||||
expect(isSafeUrl('vbscript:MsgBox("xss")')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks file: protocol', () => {
|
||||
expect(isSafeUrl('file:///etc/passwd')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks empty strings', () => {
|
||||
expect(isSafeUrl('')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks whitespace-only strings', () => {
|
||||
expect(isSafeUrl(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks unknown/custom protocols', () => {
|
||||
expect(isSafeUrl('custom:payload')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdown artifacts', () => {
|
||||
describe('getMarkdownFiles', () => {
|
||||
|
|
@ -41,7 +109,7 @@ describe('markdown artifacts', () => {
|
|||
const markdown = '# Test';
|
||||
const files = getMarkdownFiles(markdown);
|
||||
|
||||
expect(files['/components/ui/MarkdownRenderer.tsx']).toContain('import Markdown from');
|
||||
expect(files['/components/ui/MarkdownRenderer.tsx']).toContain('import ReactMarkdown from');
|
||||
expect(files['/components/ui/MarkdownRenderer.tsx']).toContain('MarkdownRendererProps');
|
||||
expect(files['/components/ui/MarkdownRenderer.tsx']).toContain(
|
||||
'export default MarkdownRenderer',
|
||||
|
|
@ -162,13 +230,29 @@ describe('markdown artifacts', () => {
|
|||
});
|
||||
|
||||
describe('markdown component structure', () => {
|
||||
it('should generate a MarkdownRenderer component that uses marked-react', () => {
|
||||
it('should generate a MarkdownRenderer component with safe markdown rendering', () => {
|
||||
const files = getMarkdownFiles('# Test');
|
||||
const rendererCode = files['/components/ui/MarkdownRenderer.tsx'];
|
||||
|
||||
// Verify the component imports and uses Markdown from marked-react
|
||||
expect(rendererCode).toContain("import Markdown from 'marked-react'");
|
||||
expect(rendererCode).toContain('<Markdown gfm={true} breaks={true}>{content}</Markdown>');
|
||||
expect(rendererCode).toContain("import ReactMarkdown from 'react-markdown'");
|
||||
expect(rendererCode).toContain("import remarkBreaks from 'remark-breaks'");
|
||||
expect(rendererCode).toContain('skipHtml={true}');
|
||||
expect(rendererCode).toContain('SAFE_PROTOCOLS');
|
||||
expect(rendererCode).toContain('isSafeUrl');
|
||||
expect(rendererCode).toContain('urlTransform={urlTransform}');
|
||||
expect(rendererCode).toContain('remarkPlugins={remarkPlugins}');
|
||||
expect(rendererCode).toContain('isSafeUrl(url) ? url : null');
|
||||
});
|
||||
|
||||
it('should embed isSafeUrl logic matching the exported version', () => {
|
||||
const files = getMarkdownFiles('# Test');
|
||||
const rendererCode = files['/components/ui/MarkdownRenderer.tsx'];
|
||||
|
||||
expect(rendererCode).toContain("new Set(['http:', 'https:', 'mailto:', 'tel:'])");
|
||||
expect(rendererCode).toContain('new URL(trimmed).protocol');
|
||||
expect(rendererCode).toContain("trimmed.startsWith('/')");
|
||||
expect(rendererCode).toContain("trimmed.startsWith('#')");
|
||||
expect(rendererCode).toContain("trimmed.startsWith('.')");
|
||||
});
|
||||
|
||||
it('should pass markdown content to the Markdown component', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue