mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
Fix html content detection regex to not include markdown autolinks (#3720)
This commit is contained in:
Generated
+2191
-2
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" />,
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user