Ошибка XSS при рендеринге Markdown — одна из самых частых уязвимостей на сайтах с пользовательским контентом. Например, злоумышленник вводит [clickme](javascript:alert(1)), и если парсер не санирует, получает выполнение JavaScript в браузере другого пользователя. За 5 лет мы реализовали более 20 проектов Markdown-редакторов с live-предпросмотром и GFM, и знаем как избежать типовых ошибок. На старте проводим аудит требований и выбираем оптимальное решение, что экономит до 30% времени на отладку, а в денежном выражении — до 40% бюджета.
Проблемы, которые решаем
- XSS через Markdown: стандартный marked.js по умолчанию не экранирует, нужен DOMPurify. Серверный sanitize (CommonMark с
html_input=strip) устраняет риски на 99%. Без него вы рискуете потерей данных и репутацией. - Hydration mismatch при SSR: React ререндерит предпросмотр на клиенте, если HTML не совпадает с серверным — исправляем через suppressHydrationWarning или отключаем SSR для редактора. Это сокращает время деплоя на 2-3 дня.
- Производительность live-preview: каждый ввод символа запускает парсинг HTML — буферизуем debounce 100ms и используем виртуализацию. Это снижает нагрузку на CPU на 60% и улучшает INP на 40%.
Как мы это делаем
Выбираем библиотеку под задачу. Используем @uiw/react-md-editor для типовых проектов, кастомный редактор на CodeMirror — для высоконагруженных систем. CodeMirror 6 с marked.js на 40% легче по размеру бандла (gzip ~30KB против ~40KB у @uiw/react-md-editor), но требует вдвое больше кода для интеграции. Сравнение в таблице:
| Библиотека | Live-предпросмотр | GFM | Загрузка изображений | SSR | Вес (gzip) |
|---|---|---|---|---|---|
| @uiw/react-md-editor | Да | Да | Нет (кастомная) | Да | ~40KB |
| CodeMirror 6 + marked.js | Да | Да | Нет (кастомная) | Нет | ~30KB + marked |
| TipTap | Да | Через плагины | Через плагины | Осторожно | ~150KB |
Вторая таблица — сравнение методов санитизации:
| Метод | Защита от XSS | Производительность | Сложность |
|---|---|---|---|
| Клиентский DOMPurify | 99% | ~2ms на 10KB | Низкая |
| Серверный CommonMark (strip) | 99.9% | ~1ms на 10KB | Средняя |
| Комбинированный | 99.99% | ~3ms на 10KB | Средняя |
Быстрый старт с @uiw/react-md-editor
import MDEditor from '@uiw/react-md-editor';
import { useState } from 'react';
function MarkdownEditor({ initialValue = '', onChange }: EditorProps) {
const [value, setValue] = useState(initialValue);
const handleChange = (val?: string) => {
const markdown = val ?? '';
setValue(markdown);
onChange?.(markdown);
};
return (
<MDEditor
value={value}
onChange={handleChange}
height={400}
preview="live"
hideToolbar={false}
commands={[
MDEditor.commands.bold,
MDEditor.commands.italic,
MDEditor.commands.title,
MDEditor.commands.divider,
MDEditor.commands.link,
MDEditor.commands.image,
MDEditor.commands.code,
MDEditor.commands.codeBlock,
MDEditor.commands.divider,
MDEditor.commands.fullscreen,
]}
/>
);
}
Кастомная реализация на CodeMirror 6 + marked.js
import { EditorView, basicSetup } from 'codemirror';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
function createMarkdownEditor(container: HTMLElement, previewContainer: HTMLElement) {
const view = new EditorView({
doc: '',
extensions: [
basicSetup,
markdown(),
oneDark,
EditorView.updateListener.of(update => {
if (update.docChanged) {
const markdown = update.state.doc.toString();
const html = marked(markdown, { breaks: true, gfm: true });
previewContainer.innerHTML = DOMPurify.sanitize(html as string);
}
}),
],
parent: container,
});
return view;
}
Загрузка изображений из редактора
import * as commands from '@uiw/react-md-editor/commands';
const imageUploadCommand: commands.ICommand = {
name: 'upload-image',
keyCommand: 'upload-image',
buttonProps: { 'aria-label': 'Загрузить изображение' },
icon: <ImageIcon />,
execute: async (state, api) => {
const file = await openFilePicker(['image/jpeg', 'image/png', 'image/webp']);
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const { data } = await api.post('/api/media/upload', formData);
const imageMarkdown = ``;
api.replaceSelection(imageMarkdown);
},
};
async function openFilePicker(accept: string[]): Promise<File | null> {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = accept.join(',');
input.onchange = () => resolve(input.files?.[0] ?? null);
input.click();
});
}
Почему стоит хранить Markdown отдельно от HTML?
Хранение исходного Markdown даёт гибкость: возможность редактирования, преобразование в разные форматы (PDF, DOCX), поиск по тексту. HTML — кэшируем для ускорения отдачи. Это стандартная практика CommonMark specification. Дополнительно, такой подход облегчает миграцию контента между системами.
Как обеспечить безопасный рендеринг?
Sanitize на сервере (CommonMark с html_input=strip, max_nesting) и на клиенте (DOMPurify). Никогда не полагайтесь только на одну сторону. Комбинированный подход даёт 99.99% защиту. Конфигурация серверного парсера может выглядеть так:
$safeHtml = $parser->safeParse($markdown)->getContent();
Процесс работы
- Аналитика: определяем требования (GFM, загрузка медиа, темы, SSR).
- Проектирование: выбор библиотеки, архитектура компонентов.
- Реализация: интеграция, кастомные команды (загрузка, эмодзи).
- Тестирование: unit-тесты для sanitization, e2e-тесты для UX.
- Деплой: настройка кэширования, мониторинг ошибок.
Что входит в работу
- Выбор и интеграция библиотеки редактора.
- Реализация live-предпросмотра с поддержкой GFM.
- Настройка загрузки изображений (drag&drop, вставка).
- Серверный и клиентский sanitize.
- SSR-совместимость (если требуется).
- Документация по использованию и доработке.
- Поддержка в течение 30 дней после деплоя.
Сроки и стоимость
Срок реализации: от 2 до 7 дней в зависимости от сложности. Стоимость рассчитывается индивидуально после анализа проекта. Получите консультацию — оценим ваш кейс. Обращайтесь — поможем с выбором и реализацией.
Чек-лист для готового редактора
- [ ] Поддержка GFM (таблицы, списки, ссылки)
- [ ] Загрузка изображений (drag & drop или через вставку)
- [ ] Live-предпросмотр с debounce 100ms
- [ ] Серверный sanitize (html_input=strip, max_nesting)
- [ ] Клиентский sanitize (DOMPurify)
- [ ] Хранение Markdown в БД, кэширование HTML
- [ ] SSR совместимость (suppressHydrationWarning)
- [ ] Полноэкранный режим
- [ ] Синтаксическая подсветка блоков кода
- [ ] Экспорт в HTML/Markdown
Типичные ошибки при реализации
- Отсутствие серверного sanitization (риск XSS).
- Игнорирование debounce для preview — лаги при наборе.
- Хранение только HTML (потеря возможности редактировать).
- Неправильная обработка hydration в Next.js при SSR.
Свяжитесь с нами для консультации — поможем выбрать оптимальное решение для вашего проекта. С нами вы получите надёжный Markdown-редактор, соответствующий современным стандартам безопасности и производительности.







