fix(admin-redesign): security — stored XSS via user name in onclick

Security review caught: per-row hover actions (users.js) and async

user picker (shop.js, gam.js) interpolated user-controlled name into

JS string literals inside onclick. LS.esc() escapes & < > " but

NOT backslash; the .replace(/'/g, '\'') fallback was broken.

Attack: any authenticated user could set their name to

  a\'); alert(1); //

via PATCH /api/auth/profile (stripTags doesn't strip \) — admin

viewing the users/shop/gam picker would execute arbitrary JS.

Fix: switch from JS-string interpolation to data-uid/data-name

attributes, read via dataset in handler. esc() correctly escapes

for HTML-attribute context; dataset returns the raw string with

zero parse re-entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-17 00:30:34 +03:00
parent ce183ef14d
commit bf70c3d7d7
3 changed files with 34 additions and 18 deletions
+7 -5
View File
@@ -118,17 +118,19 @@
_gamSearchTimer = setTimeout(async () => {
try {
const r = await LS.adminGetUsers({ q, limit: 8 });
box.innerHTML = (r.users || []).map(u => `<div class="us-item" onclick="gamPickUser(${u.id}, '${esc(u.name || u.email)}', '${prefix}')">
<span>${esc(u.name || u.email)}</span><span class="us-role">${u.role}</span>
const label = u => u.name || u.email;
box.innerHTML = (r.users || []).map(u => `<div class="us-item" data-uid="${u.id}" data-name="${esc(label(u))}" data-prefix="${esc(prefix)}" onclick="gamPickUser(this)">
<span>${esc(label(u))}</span><span class="us-role">${esc(u.role)}</span>
</div>`).join('') || '<div class="us-item" style="color:var(--text-3)">Не найдено</div>';
box.classList.add('open');
} catch(e) { box.classList.remove('open'); }
}, 300);
}
function gamPickUser(id, name, prefix) {
document.getElementById(prefix + '-uid').value = id;
document.getElementById(prefix + '-user').value = name;
function gamPickUser(el) {
const prefix = el.dataset.prefix;
document.getElementById(prefix + '-uid').value = el.dataset.uid;
document.getElementById(prefix + '-user').value = el.dataset.name || '';
document.getElementById(prefix + '-results').classList.remove('open');
}
+6 -5
View File
@@ -156,17 +156,18 @@
_shopSearchTimer = setTimeout(async () => {
try {
const r = await LS.adminGetUsers({ q, limit: 8 });
box.innerHTML = (r.users || []).map(u => `<div class="us-item" onclick="shopPickUser(${u.id}, '${esc(u.name || u.email)}')">
<span>${esc(u.name || u.email)}</span><span class="us-role">${u.role}</span>
const label = u => u.name || u.email;
box.innerHTML = (r.users || []).map(u => `<div class="us-item" data-uid="${u.id}" data-name="${esc(label(u))}" onclick="shopPickUser(this)">
<span>${esc(label(u))}</span><span class="us-role">${esc(u.role)}</span>
</div>`).join('') || '<div class="us-item" style="color:var(--text-3)">Не найдено</div>';
box.classList.add('open');
} catch(e) { box.classList.remove('open'); }
}, 300);
}
function shopPickUser(id, name) {
document.getElementById('shop-award-uid').value = id;
document.getElementById('shop-award-user').value = name;
function shopPickUser(el) {
document.getElementById('shop-award-uid').value = el.dataset.uid;
document.getElementById('shop-award-user').value = el.dataset.name || '';
document.getElementById('shop-award-results').classList.remove('open');
}
+21 -8
View File
@@ -119,19 +119,27 @@
}
const banIcon = u.is_banned ? ICONS.unlock : ICONS.ban;
const banLabel = u.is_banned ? 'Разблокировать' : 'Заблокировать';
// Pass uid/name via data-* attributes (esc() escapes & < > " for the attribute
// context; dataset reads back the raw string — no JS-string injection surface).
return `<div class="row-actions" onclick="event.stopPropagation()">
<button type="button" class="row-action-btn" title="${banLabel}"
onclick="event.stopPropagation();quickToggleBan(${u.id},${u.is_banned?1:0},this)">${banIcon}</button>
data-uid="${u.id}" data-banned="${u.is_banned?1:0}"
onclick="event.stopPropagation();quickToggleBan(this)">${banIcon}</button>
<button type="button" class="row-action-btn" title="Начислить монеты"
onclick="event.stopPropagation();quickAwardCoins(${u.id},'${esc(u.name).replace(/'/g, "\\'")}')">${ICONS.coins}</button>
data-uid="${u.id}" data-name="${esc(u.name)}"
onclick="event.stopPropagation();quickAwardCoins(this)">${ICONS.coins}</button>
<button type="button" class="row-action-btn" title="История сессий"
onclick="event.stopPropagation();quickOpenUserSessions(${u.id})">${ICONS.history}</button>
data-uid="${u.id}"
onclick="event.stopPropagation();quickOpenUserSessions(this)">${ICONS.history}</button>
<button type="button" class="row-action-btn danger" title="Удалить пользователя"
onclick="event.stopPropagation();quickDeleteUser(${u.id},'${esc(u.name).replace(/'/g, "\\'")}',this)">${ICONS.trash}</button>
data-uid="${u.id}" data-name="${esc(u.name)}"
onclick="event.stopPropagation();quickDeleteUser(this)">${ICONS.trash}</button>
</div>`;
}
async function quickToggleBan(uid, isBanned, btn) {
async function quickToggleBan(btn) {
const uid = +btn.dataset.uid;
const isBanned = +btn.dataset.banned;
const action = isBanned ? 'Разблокировать' : 'Заблокировать';
const msg = isBanned
? 'Разблокировать пользователя? Он снова сможет войти в систему.'
@@ -152,7 +160,9 @@
}
}
function quickAwardCoins(uid, name) {
function quickAwardCoins(btn) {
const uid = +btn.dataset.uid;
const name = btn.dataset.name || '';
const body = document.createElement('div');
body.innerHTML = `
<p style="margin:0 0 14px;font-size:0.88rem;color:var(--text-2)">Начислить монеты пользователю <strong>${esc(name)}</strong>:</p>
@@ -187,7 +197,8 @@
setTimeout(() => body.querySelector('#qa-coins-amt')?.focus(), 80);
}
function quickOpenUserSessions(uid) {
function quickOpenUserSessions(btn) {
const uid = +btn.dataset.uid;
// Phase 6: open the user's deep page with the Sessions sub-tab active.
if (window.AdminRouter) AdminRouter.navigate('#users/' + uid + '/sessions');
else if (typeof window.switchTab === 'function') {
@@ -196,7 +207,9 @@
}
}
async function quickDeleteUser(uid, name, btn) {
async function quickDeleteUser(btn) {
const uid = +btn.dataset.uid;
const name = btn.dataset.name || '';
if (!await LS.confirm(
`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`,
{ title: 'Удалить пользователя', confirmText: 'Удалить навсегда' }