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:
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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: 'Удалить навсегда' }
|
||||
|
||||
Reference in New Issue
Block a user