fix(security): убрать stored-XSS в блоке columns урока (Спринт1 #4)

Блок columns хранит rich-HTML из мини-редактора и рендерился сырым в innerHTML
(единственный неэкранированный блок) — учитель мог внедрить <img onerror>/script,
исполняемый у каждого ученика (кража JWT из localStorage). Добавлен санитайзер
sanitizeRichHtml (инертный template + вырезание on*/script/iframe/javascript:),
сохраняет форматирование, но блокирует исполнение.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-12 21:56:46 +03:00
parent dd5dfee5c9
commit 95fee1d8c5
+18 -1
View File
@@ -897,6 +897,23 @@
/* ── helpers ── */ /* ── helpers ── */
function escAll(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } function escAll(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
/* Санитайзер rich-HTML (блок columns хранит форматированный HTML из мини-редактора).
Парсим в инертный <template> (картинки/скрипты НЕ исполняются), вырезаем опасное,
сериализуем обратно. Блокирует on*-обработчики, script/iframe, javascript:/data: URL. */
function sanitizeRichHtml(html) {
const tpl = document.createElement('template');
tpl.innerHTML = String(html || '');
tpl.content.querySelectorAll('script,style,iframe,object,embed,link,meta,form,base').forEach(n => n.remove());
tpl.content.querySelectorAll('*').forEach(el => {
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
if (name.startsWith('on')) el.removeAttribute(attr.name);
else if (name === 'style') el.removeAttribute(attr.name);
else if (/^(href|src|xlink:href)$/.test(name) && /^\s*(javascript|data|vbscript):/i.test(attr.value || '')) el.removeAttribute(attr.name);
}
});
return tpl.innerHTML;
}
function fmtTime(s) { function fmtTime(s) {
const d = new Date(s && s.includes('T') ? s : (s||'').replace(' ','T')+'Z'); const d = new Date(s && s.includes('T') ? s : (s||'').replace(' ','T')+'Z');
const diff = Date.now() - d.getTime(); const diff = Date.now() - d.getTime();
@@ -1376,7 +1393,7 @@
case 'columns': { case 'columns': {
const cols = Array.isArray(d.cols) ? d.cols : []; const cols = Array.isArray(d.cols) ? d.cols : [];
return `<div class="lesson-block block-columns cols-${cols.length}"> return `<div class="lesson-block block-columns cols-${cols.length}">
${cols.map(c => `<div class="block-col">${c.content || ''}</div>`).join('')} ${cols.map(c => `<div class="block-col">${sanitizeRichHtml(c.content || '')}</div>`).join('')}
</div>`; </div>`;
} }