Harden admin security controls

This commit is contained in:
2026-05-31 22:23:51 +09:00
parent b27968e5a7
commit ae72b4c739
14 changed files with 378 additions and 136 deletions
+27 -3
View File
@@ -695,6 +695,7 @@ function toggleTheme() {
applyTheme(saved || prefer);
})();
let isAdmin = false;
let csrfToken = '';
let profile = null;
async function init() {
@@ -707,12 +708,21 @@ async function checkAuth() {
const res = await fetch('api/auth.php?action=check');
const data = await res.json();
isAdmin = data.authenticated === true;
csrfToken = data.csrf_token || '';
document.querySelectorAll('.admin-controls').forEach(el => {
el.classList.toggle('hidden', !isAdmin);
});
} catch (e) { isAdmin = false; }
}
function csrfHeaders(headers = {}) {
return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers;
}
function csrfFetch(url, options = {}) {
return fetch(url, { ...options, headers: csrfHeaders(options.headers || {}) });
}
async function loadProfile() {
try {
const res = await fetch('api/profile.php');
@@ -734,7 +744,7 @@ function renderProfile() {
const avatarEl = document.getElementById('avatarEl');
if (profile.avatar) {
avatarEl.style.backgroundImage = `url('${escapeAttr(profile.avatar)}')`;
avatarEl.style.backgroundImage = `url('${escapeAttr(sanitizeUrl(profile.avatar))}')`;
avatarEl.style.backgroundSize = 'cover';
avatarEl.style.backgroundPosition = 'center';
avatarEl.innerHTML = '';
@@ -818,8 +828,10 @@ function renderProfile() {
</a>
`;
}
const safeHref = sanitizeUrl(c.href);
if (!safeHref) return '';
return `
<a href="${escapeAttr(c.href)}" class="contact-card" target="_blank">
<a href="${escapeAttr(safeHref)}" class="contact-card" target="_blank" rel="noopener noreferrer">
<i class="${c.icon}"></i>
<span class="label">${c.label}</span>
<span class="value">${escapeHtml(c.value)}</span>
@@ -1040,7 +1052,7 @@ async function saveProfile() {
};
try {
const res = await fetch('api/profile.php', {
const res = await csrfFetch('api/profile.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
@@ -1071,6 +1083,18 @@ function escapeHtml(str) {
}
function escapeAttr(str) { return escapeHtml(str); }
function sanitizeUrl(url) {
if (!url) return '';
const trimmed = String(url).trim();
if (/^uploads\/[A-Za-z0-9가-힣._~!$&'()*+,;=:@%/-]+$/u.test(trimmed) &&
!trimmed.includes('..') && !/(^|\/)\./.test(trimmed)) return trimmed;
try {
const parsed = new URL(trimmed, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return trimmed;
} catch (e) {}
return '';
}
// ESC 키로 모달 닫기 (바깥 클릭 닫기 제거)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {