Fix html content detection regex to not include markdown autolinks (#3720)

This commit is contained in:
Zane
2025-07-30 09:40:12 -07:00
committed by GitHub
parent e5ac6412ec
commit f47836bbe8
8 changed files with 2845 additions and 35 deletions
+2191 -2
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -30,6 +30,10 @@
"lint:check": "npm run typecheck && eslint \"src/**/*.{ts,tsx}\" --max-warnings 0 --no-warn-ignored",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"",
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"prepare": "cd ../.. && husky install",
"start-alpha-gui": "ALPHA=true npm run start-gui"
},
@@ -100,6 +104,9 @@
"@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.10",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/cors": "^2.8.17",
"@types/electron": "^1.4.38",
"@types/electron-squirrel-startup": "^1.0.2",
@@ -119,12 +126,14 @@
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"husky": "^8.0.0",
"jsdom": "^25.0.1",
"lint-staged": "^15.4.1",
"postcss": "^8.4.47",
"prettier": "^3.4.2",
"tailwindcss": "^4.1.10",
"typescript": "~5.5.0",
"vite": "^6.3.4"
"vite": "^6.3.4",
"vitest": "^2.1.8"
},
"keywords": [],
"license": "Apache-2.0",
@@ -0,0 +1,262 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import MarkdownContent from './MarkdownContent';
// Mock the icons to avoid import issues
vi.mock('./icons', () => ({
Check: () => <div data-testid="check-icon"></div>,
Copy: () => <div data-testid="copy-icon">📋</div>,
}));
describe('MarkdownContent', () => {
describe('HTML Security Integration', () => {
it('renders safe markdown content normally', async () => {
const content = `# Test Title
Visit <https://example.com> for more info.
Contact <admin@example.com> for support.
Use \`Array<T>\` for generics.`;
render(<MarkdownContent content={content} />);
await waitFor(() => {
expect(screen.getByText('Test Title')).toBeInTheDocument();
expect(screen.getByText(/Visit/)).toBeInTheDocument();
expect(screen.getByText(/for more info/)).toBeInTheDocument();
expect(screen.getByText(/Contact/)).toBeInTheDocument();
expect(screen.getByText(/for support/)).toBeInTheDocument();
});
// Should not create extra code blocks for safe content
const codeBlocks = screen.queryAllByText(/```html/);
expect(codeBlocks).toHaveLength(0);
});
it('wraps dangerous HTML in code blocks', async () => {
const content = `# Security Test
This is safe text.
<script>alert('xss')</script>
More safe text.`;
render(<MarkdownContent content={content} />);
await waitFor(() => {
expect(screen.getByText('Security Test')).toBeInTheDocument();
expect(screen.getByText('This is safe text.')).toBeInTheDocument();
expect(screen.getByText('More safe text.')).toBeInTheDocument();
});
// The script tag should be in a code block, not executed
const scriptElements = document.querySelectorAll('script');
expect(scriptElements).toHaveLength(0); // No actual script tags should be created
// Should find the script content in a code block (text may be split across spans)
await waitFor(() => {
expect(screen.getByText(/alert/)).toBeInTheDocument();
expect(screen.getByText(/xss/)).toBeInTheDocument();
});
});
it('handles HTML comments securely', async () => {
const content = `# Comment Test
<!-- This is a malicious comment -->
Normal text continues.`;
render(<MarkdownContent content={content} />);
await waitFor(() => {
expect(screen.getByText('Comment Test')).toBeInTheDocument();
expect(screen.getByText('Normal text continues.')).toBeInTheDocument();
});
// Comment should be in a code block
await waitFor(() => {
expect(screen.getByText(/This is a malicious comment/)).toBeInTheDocument();
});
});
it('preserves existing code blocks', async () => {
const content = `# Code Block Test
\`\`\`javascript
const html = "<div>This is safe in a code block</div>";
console.log(html);
\`\`\`
<div>This should be wrapped</div>`;
render(<MarkdownContent content={content} />);
await waitFor(() => {
expect(screen.getByText('Code Block Test')).toBeInTheDocument();
});
// Should preserve the original JavaScript code block (text may be split)
await waitFor(() => {
expect(screen.getByText(/const/)).toBeInTheDocument();
expect(screen.getAllByText(/html/)).toHaveLength(2); // Variable name and function parameter
});
// The div outside the code block should be wrapped
await waitFor(() => {
expect(screen.getByText(/This should be wrapped/)).toBeInTheDocument();
});
});
it('handles mixed safe and unsafe content', async () => {
const content = `# Mixed Content Test
1. Auto-link: <https://block.dev>
2. Inline code: \`const x = Array<T>();\`
3. Real markup: <input type="text" disabled>
4. Placeholder path: <project-root>/src`;
render(<MarkdownContent content={content} />);
await waitFor(() => {
expect(screen.getByText('Mixed Content Test')).toBeInTheDocument();
expect(screen.getByText(/Auto-link/)).toBeInTheDocument();
expect(screen.getByText(/Inline code/)).toBeInTheDocument();
expect(screen.getByText(/Real markup/)).toBeInTheDocument();
expect(screen.getByText(/Placeholder path/)).toBeInTheDocument();
});
// Only the input tag should be wrapped
await waitFor(() => {
expect(screen.getByText(/input/)).toBeInTheDocument();
expect(screen.getByText(/type/)).toBeInTheDocument();
expect(screen.getByText(/disabled/)).toBeInTheDocument();
});
// Should not have actual input elements in the DOM
const inputElements = document.querySelectorAll('input');
expect(inputElements).toHaveLength(0);
});
});
describe('Code Block Functionality', () => {
it('renders code blocks with syntax highlighting', async () => {
const content = `\`\`\`javascript
console.log('Hello, World!');
\`\`\``;
render(<MarkdownContent content={content} />);
await waitFor(() => {
expect(screen.getByText(/console/)).toBeInTheDocument();
expect(screen.getByText(/log/)).toBeInTheDocument();
expect(screen.getByText(/Hello, World!/)).toBeInTheDocument();
});
});
it('renders inline code', async () => {
const content = 'Use `console.log()` to debug.';
render(<MarkdownContent content={content} />);
await waitFor(() => {
expect(screen.getByText(/Use/)).toBeInTheDocument();
expect(screen.getByText(/to debug/)).toBeInTheDocument();
expect(screen.getByText('console.log()')).toBeInTheDocument();
});
});
});
describe('Markdown Features', () => {
it('renders headers correctly', async () => {
const content = `# H1 Header
## H2 Header
### H3 Header`;
render(<MarkdownContent content={content} />);
await waitFor(() => {
expect(screen.getByRole('heading', { level: 1, name: 'H1 Header' })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 2, name: 'H2 Header' })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 3, name: 'H3 Header' })).toBeInTheDocument();
});
});
it('renders lists correctly', async () => {
const content = `- Item 1
- Item 2
- Item 3
1. Numbered 1
2. Numbered 2`;
render(<MarkdownContent content={content} />);
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
expect(screen.getByText('Item 3')).toBeInTheDocument();
expect(screen.getByText('Numbered 1')).toBeInTheDocument();
expect(screen.getByText('Numbered 2')).toBeInTheDocument();
});
});
it('renders links with correct attributes', async () => {
const content = '[Visit Block](https://block.dev)';
render(<MarkdownContent content={content} />);
await waitFor(() => {
const link = screen.getByRole('link', { name: 'Visit Block' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://block.dev');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
});
it('renders tables correctly', async () => {
const content = `| Name | Value |
|------|-------|
| Test | 123 |
| Demo | 456 |`;
render(<MarkdownContent content={content} />);
await waitFor(() => {
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Value')).toBeInTheDocument();
expect(screen.getByText('Test')).toBeInTheDocument();
expect(screen.getByText('123')).toBeInTheDocument();
expect(screen.getByText('Demo')).toBeInTheDocument();
expect(screen.getByText('456')).toBeInTheDocument();
});
});
});
describe('Error Handling', () => {
it('handles empty content gracefully', async () => {
render(<MarkdownContent content="" />);
// Should not throw and should render the component
const container = document.querySelector('.w-full.overflow-x-hidden');
expect(container).toBeInTheDocument();
});
it('handles malformed markdown gracefully', async () => {
const content = `# Unclosed header
[Unclosed link(https://example.com
\`\`\`
Unclosed code block`;
render(<MarkdownContent content={content} />);
await waitFor(() => {
// Should still render what it can
expect(screen.getByText('Unclosed header')).toBeInTheDocument();
});
});
});
});
+14 -32
View File
@@ -1,9 +1,10 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Check, Copy } from './icons';
import { wrapHTMLInCodeBlock } from '../utils/htmlSecurity';
interface CodeProps extends React.ClassAttributes<HTMLElement>, React.HTMLAttributes<HTMLElement> {
inline?: boolean;
@@ -76,37 +77,19 @@ const MarkdownCode = React.forwardRef(function MarkdownCode(
);
});
// Detect if content contains HTML
const containsHTML = (str: string) => {
const htmlRegex = /<[^>]*>/;
return htmlRegex.test(str);
};
// Wrap HTML content in code blocks
const wrapHTMLInCodeBlock = (content: string) => {
if (containsHTML(content)) {
// Split content by code blocks to preserve existing ones
const parts = content.split(/(```[\s\S]*?```)/g);
return parts
.map((part) => {
// If part is already a code block, leave it as is
if (part.startsWith('```') && part.endsWith('```')) {
return part;
}
// If part contains HTML, wrap it in HTML code block
if (containsHTML(part)) {
return `\`\`\`html\n${part}\n\`\`\``;
}
return part;
})
.join('\n');
}
return content;
};
export default function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
// Process content before rendering
const processedContent = wrapHTMLInCodeBlock(content);
const [processedContent, setProcessedContent] = useState(content);
useEffect(() => {
try {
const processed = wrapHTMLInCodeBlock(content);
setProcessedContent(processed);
} catch (error) {
console.error('Error processing content:', error);
// Fallback to original content if processing fails
setProcessedContent(content);
}
}, [content]);
return (
<div className="w-full overflow-x-hidden">
@@ -127,7 +110,6 @@ export default function MarkdownContent({ content, className = '' }: MarkdownCon
prose-ol:my-2
prose-ul:mt-0 prose-ul:mb-3
prose-li:m-0
${className}`}
components={{
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
+18
View File
@@ -0,0 +1,18 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Mock console methods to avoid noise in tests
// eslint-disable-next-line no-undef
global.console = {
...console,
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
// Mock window.navigator.clipboard for copy functionality tests
Object.assign(navigator, {
clipboard: {
writeText: vi.fn(() => Promise.resolve()),
},
});
+269
View File
@@ -0,0 +1,269 @@
import { describe, it, expect } from 'vitest';
import { containsHTML, wrapHTMLInCodeBlock } from '../utils/htmlSecurity';
describe('HTML Security Detection', () => {
describe('containsHTML', () => {
describe('should detect dangerous HTML tags', () => {
it('detects script tags', () => {
expect(containsHTML('<script>alert("xss")</script>')).toBe(true);
expect(containsHTML('<script src="evil.js"></script>')).toBe(true);
expect(containsHTML('<script>')).toBe(true);
});
it('detects style tags', () => {
expect(containsHTML('<style>body { display: none; }</style>')).toBe(true);
expect(containsHTML('<style>')).toBe(true);
});
it('detects iframe tags', () => {
expect(containsHTML('<iframe src="evil.com"></iframe>')).toBe(true);
expect(containsHTML('<iframe>')).toBe(true);
});
it('detects form elements', () => {
expect(containsHTML('<form action="/submit"></form>')).toBe(true);
expect(containsHTML('<input type="text" name="password">')).toBe(true);
expect(containsHTML('<button onclick="evil()">Click</button>')).toBe(true);
});
it('detects layout-affecting tags', () => {
expect(containsHTML('<div class="container">content</div>')).toBe(true);
expect(containsHTML('<span style="color:red">text</span>')).toBe(true);
expect(containsHTML('<br/>')).toBe(true);
expect(containsHTML('<hr>')).toBe(true);
expect(containsHTML('<img src="image.jpg" alt="test">')).toBe(true);
});
it('detects HTML comments', () => {
expect(containsHTML('<!-- this is a comment -->')).toBe(true);
expect(containsHTML('<!-- multi\nline\ncomment -->')).toBe(true);
});
});
describe('should NOT detect safe content', () => {
it('ignores auto-links', () => {
expect(containsHTML('<https://example.com>')).toBe(false);
expect(containsHTML('<http://test.org>')).toBe(false);
expect(containsHTML('<https://block.dev/docs>')).toBe(false);
});
it('ignores email addresses', () => {
expect(containsHTML('<user@example.com>')).toBe(false);
expect(containsHTML('<admin@block.dev>')).toBe(false);
expect(containsHTML('<test.email+tag@domain.co.uk>')).toBe(false);
});
it('ignores TypeScript generics and placeholders', () => {
expect(containsHTML('Array<T>')).toBe(false);
expect(containsHTML('Promise<string>')).toBe(false);
expect(containsHTML('<project-root>')).toBe(false);
expect(containsHTML('<filename>')).toBe(false);
expect(containsHTML('<<not a tag>>')).toBe(false);
});
it('ignores content already in code blocks', () => {
expect(containsHTML('```html\n<div>safe</div>\n```')).toBe(false);
expect(containsHTML('`<script>safe</script>`')).toBe(false);
expect(containsHTML('Here is `<br/>` in inline code')).toBe(false);
});
it('ignores plain text', () => {
expect(containsHTML('This is just plain text')).toBe(false);
expect(containsHTML('No HTML here!')).toBe(false);
expect(containsHTML('')).toBe(false);
});
it('ignores mathematical expressions', () => {
expect(containsHTML('x < y && y > z')).toBe(false);
expect(containsHTML('if (a < b && c > d)')).toBe(false);
});
});
describe('edge cases', () => {
it('handles mixed content correctly', () => {
// Real HTML mixed with safe content
expect(containsHTML('Visit <https://example.com> and <div>click here</div>')).toBe(true);
// Only safe content
expect(containsHTML('Email <user@test.com> about <project-root> setup')).toBe(false);
});
it('handles malformed HTML', () => {
expect(containsHTML('<div unclosed')).toBe(false); // This doesn't match our regex pattern
expect(containsHTML('<>')).toBe(false);
expect(containsHTML('< div >')).toBe(false);
});
});
});
describe('wrapHTMLInCodeBlock', () => {
describe('should wrap dangerous HTML', () => {
it('wraps single line HTML', () => {
const input = '<script>alert("xss")</script>';
const expected = '```html\n<script>alert("xss")</script>\n```';
expect(wrapHTMLInCodeBlock(input)).toBe(expected);
});
it('wraps HTML comments', () => {
const input = '<!-- malicious comment -->';
const expected = '```html\n<!-- malicious comment -->\n```';
expect(wrapHTMLInCodeBlock(input)).toBe(expected);
});
it('wraps mixed content selectively', () => {
const input = `Normal text
<div>This should be wrapped</div>
More normal text`;
const expected = `Normal text
\`\`\`html
<div>This should be wrapped</div>
\`\`\`
More normal text`;
expect(wrapHTMLInCodeBlock(input)).toBe(expected);
});
});
describe('should preserve safe content', () => {
it('preserves auto-links', () => {
const input = 'Visit <https://example.com> for more info';
expect(wrapHTMLInCodeBlock(input)).toBe(input);
});
it('preserves email addresses', () => {
const input = 'Contact <admin@example.com> for help';
expect(wrapHTMLInCodeBlock(input)).toBe(input);
});
it('preserves TypeScript generics', () => {
const input = 'const arr: Array<string> = []';
expect(wrapHTMLInCodeBlock(input)).toBe(input);
});
it('preserves existing code blocks', () => {
const input = `# Title
\`\`\`javascript
const x = "<div>this is safe</div>";
\`\`\`
Normal text`;
expect(wrapHTMLInCodeBlock(input)).toBe(input);
});
it('preserves inline code', () => {
const input = 'Use `<br/>` for line breaks';
expect(wrapHTMLInCodeBlock(input)).toBe(input);
});
});
describe('complex scenarios', () => {
it('handles multiple HTML lines correctly', () => {
const input = `# Test Message
Normal paragraph
<div>First HTML line</div>
<span>Second HTML line</span>
More normal text`;
const expected = `# Test Message
Normal paragraph
\`\`\`html
<div>First HTML line</div>
\`\`\`
\`\`\`html
<span>Second HTML line</span>
\`\`\`
More normal text`;
expect(wrapHTMLInCodeBlock(input)).toBe(expected);
});
it('respects existing code block boundaries', () => {
const input = `Before code block
\`\`\`html
<div>This is already safe</div>
<script>This is also safe in here</script>
\`\`\`
<div>This should be wrapped</div>`;
const expected = `Before code block
\`\`\`html
<div>This is already safe</div>
<script>This is also safe in here</script>
\`\`\`
\`\`\`html
<div>This should be wrapped</div>
\`\`\``;
expect(wrapHTMLInCodeBlock(input)).toBe(expected);
});
it('handles the test suite scenarios', () => {
// Test Message 1: One-liners
const test1 = `<https://example.com>
<user@example.com>
\`<T>\``;
expect(wrapHTMLInCodeBlock(test1)).toBe(test1);
// Test Message 2: Mixed content - our function wraps the entire line, not just the HTML part
const test2 = `Here's a link <https://example.com> and HTML <div>content</div>`;
const expected2 = `\`\`\`html
Here's a link <https://example.com> and HTML <div>content</div>
\`\`\``;
expect(wrapHTMLInCodeBlock(test2)).toBe(expected2);
// Test Message 7: Comment-only
const test7 = `<!-- top-level html comment -->`;
const expected7 = `\`\`\`html
<!-- top-level html comment -->
\`\`\``;
expect(wrapHTMLInCodeBlock(test7)).toBe(expected7);
});
});
describe('edge cases', () => {
it('handles empty input', () => {
expect(wrapHTMLInCodeBlock('')).toBe('');
});
it('handles only whitespace', () => {
const input = ' \n \n ';
expect(wrapHTMLInCodeBlock(input)).toBe(input);
});
it('handles nested code block scenarios', () => {
const input = `\`\`\`
<div>safe in code block</div>
\`\`\`
<div>unsafe outside</div>
\`\`\`
<span>also safe in code block</span>
\`\`\``;
const expected = `\`\`\`
<div>safe in code block</div>
\`\`\`
\`\`\`html
<div>unsafe outside</div>
\`\`\`
\`\`\`
<span>also safe in code block</span>
\`\`\``;
expect(wrapHTMLInCodeBlock(input)).toBe(expected);
});
});
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* HTML Security Detection Utilities
*
* These functions detect potentially dangerous HTML content in markdown
* and wrap it safely in code blocks to prevent execution.
*/
/**
* Detects if content contains potentially dangerous HTML
* @param str - The content to check
* @returns true if dangerous HTML is detected
*/
export function containsHTML(str: string): boolean {
// Remove fenced code blocks and inline code first
const withoutCodeBlocks = str.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '');
// Check for HTML comments first
const commentRegex = /<!--[\s\S]*?-->/;
const hasComments = commentRegex.test(withoutCodeBlocks);
// Only detect potentially dangerous HTML tags that could execute or affect layout
const dangerousHTMLRegex =
/<(script|style|iframe|object|embed|form|input|button|link|meta|base|br|hr|img|div|span|p|h[1-6]|a|strong|em|b|i|u|s|pre|code|blockquote|section|article|header|footer|nav|aside|main|table|tr|td|th|ul|ol|li)(?:\s[^>]*)?(?:\s*\/?>|>[^<]*<\/\1>)/i;
const hasDangerousHTML = dangerousHTMLRegex.test(withoutCodeBlocks);
return hasComments || hasDangerousHTML;
}
/**
* Wraps HTML content in code blocks for safe display
* @param content - The markdown content to process
* @returns Processed content with HTML wrapped in code blocks
*/
export function wrapHTMLInCodeBlock(content: string): string {
const lines = content.split('\n');
let insideCodeBlock = false;
const processedLines = lines.map((line) => {
// Track if we're inside a code block
if (line.trim().startsWith('```')) {
insideCodeBlock = !insideCodeBlock;
return line;
}
// If we're inside a code block, don't process the content - just leave it as-is
if (insideCodeBlock) {
return line;
}
// Only check for HTML in lines that are NOT inside code blocks
if (containsHTML(line)) {
return `\`\`\`html\n${line}\n\`\`\``;
}
return line;
});
return processedLines.join('\n');
}
+22
View File
@@ -0,0 +1,22 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { resolve } from 'node:path'
const cfg = {
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: true,
include: ['src/**/*.{test,spec}.{js,jsx,ts,tsx}'],
},
} satisfies Record<string, any>
export default defineConfig(cfg as any)