שגיאות XSS בעת עיבוד Markdown הן בין הפגיעויות הנפוצות ביותר באתרים עם תוכן שנוצר על ידי משתמשים. לדוגמה, תוקף מזין [clickme](javascript:alert(1)), ואם המפענח אינו מבצע סינון, JavaScript רץ בדפדפן של משתמש אחר. במשך למעלה מ-5 שנים, סיפקנו למעלה מ-20 פרויקטים של עורכי Markdown עם תצוגה מקדימה חיה ו-GFM, ואנחנו יודעים כיצד להימנע מטעויות אופייניות. בתחילת הדרך, אנו בודקים את הדרישות ובוחרים בפתרון האופטימלי, מה שחוסך עד 30% מזמן הניפוי ובמונחים כספיים, עד 40% מהתקציב.
בעיות שאנו פותרים
-
XSS דרך Markdown: marked.js הסטנדרטי אינו מבצע escape כברירת מחדל; יש צורך ב-DOMPurify. סינון בצד השרת (CommonMark עם
html_input=strip) מבטל 99% מהסיכונים. בלעדיו, אתה מסכן את הנתונים ואת המוניטין שלך. -
חוסר התאמה ב-Hydration עם SSR: React מרנדר מחדש את התצוגה המקדימה בצד הלקוח אם ה-HTML אינו תואם לשרת — אנו מתקנים זאת באמצעות
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, ]} /> ); }או משביתים SSR עבור העורך. זה מקצר את זמן הפריסה ב-2-3 ימים. - ביצועי תצוגה מקדימה חיה: כל הקלדה מפעילה ניתוח HTML — אנו מבצעים חציצה עם debounce של 100ms ומשתמשים בווירטואליזציה. זה מפחית את עומס המעבד ב-60% ומשפר את INP ב-40%.
כיצד אנו עושים זאת
אנו בוחרים את הספרייה בהתאם למשימה. אנו משתמשים ב-@uiw/react-md-editor לפרויקטים טיפוסיים, ובעורך מותאם אישית על CodeMirror למערכות בעומס גבוה. CodeMirror 6 עם marked.js קל ב-40% בגודל החבילה (gzip ~30KB לעומת ~40KB עבור @uiw/react-md-editor) אך דורש פי שניים קוד אינטגרציה. טבלת השוואה:
| ספרייה | תצוגה מקדימה חיה | 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. בנוסף, גישה זו מקלה על העברת תוכן בין מערכות.
כיצד להבטיח עיבוד מאובטח?
בצע סינון בשרת (CommonMark עם 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(); }); } ) ובצד הלקוח (DOMPurify). לעולם אל תסתמך על צד אחד בלבד. הגישה המשולבת נותנת הגנה של 99.99%. תצורת מפענח שרת עשויה להיראות כך:
$safeHtml = $parser->safeParse($markdown)->getContent(); סקירת תהליך
- ניתוח: קביעת דרישות (GFM, העלאת מדיה, ערכות נושא, SSR).
- עיצוב: בחירת ספרייה, ארכיטקטורת רכיבים.
- יישום: אינטגרציה, פקודות מותאמות אישית (העלאה, אמוג'ים).
- בדיקות: בדיקות יחידה לסינון, בדיקות e2e לחוויית משתמש.
- פריסה: הגדרת מטמון, ניטור שגיאות.
מה כלול בעבודה
- בחירת ספרייה ואינטגרציה.
- יישום תצוגה מקדימה חיה עם תמיכת GFM.
- הגדרת העלאת תמונות (גרירה ושחרור, הוספה).
- סינון בצד השרת ובצד הלקוח.
- תאימות SSR (אם נדרש).
- תיעוד שימוש והתאמה אישית.
- תמיכה ל-30 יום לאחר הפריסה.
לוחות זמנים ותמחור
זמן יישום: 2 עד 7 ימים בהתאם למורכבות. התמחור מחושב באופן אישי לאחר ניתוח הפרויקט. קבל ייעוץ — אנו נעריך את המקרה שלך. צור קשר — נעזור בבחירה וביישום.
רשימת בדיקה לעורך שהושלם
- [ ] תמיכת GFM (טבלאות, רשימות, קישורים)
- [ ] העלאת תמונות (גרירה ושחרור או הוספה)
- [ ] תצוגה מקדימה חיה עם debounce של 100ms
- [ ] סינון בצד השרת (html_input=strip, max_nesting)
- [ ] סינון בצד הלקוח (DOMPurify)
- [ ] אחסון Markdown במסד נתונים, מטמון HTML
- [ ] תאימות SSR (suppressHydrationWarning)
- [ ] מצב מסך מלא
- [ ] הדגשת תחביר לבלוקי קוד
- [ ] ייצוא ל-HTML/Markdown
טעויות יישום נפוצות
- חוסר סינון בצד השרת (סיכון XSS).
- התעלמות מ-debounce לתצוגה מקדימה — פיגור בקלט.
- אחסון HTML בלבד (אובדן יכולת עריכה).
- טיפול שגוי ב-Hydration ב-Next.js עם SSR.
צור קשר לייעוץ — נעזור לך לבחור את הפתרון האופטימלי לפרויקט שלך. איתנו, אתה מקבל עורך Markdown אמין שעומד בסטנדרטים מודרניים של אבטחה וביצועים.







