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
+38 -14
View File
@@ -883,6 +883,7 @@ main { padding: 80px 8%; }
<script>
// ===== 상태 =====
let isAdmin = false;
let csrfToken = '';
// ===== 테마 =====
function applyTheme(theme) {
@@ -977,10 +978,19 @@ 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.getElementById('adminControls').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 || {}) });
}
function openLoginModal() {
if (isAdmin) { logout(); return; }
document.getElementById('loginModal').classList.add('active');
@@ -1004,6 +1014,7 @@ async function doLogin() {
});
const data = await res.json();
if (data.success) {
csrfToken = data.csrf_token || csrfToken;
isAdmin = true;
document.getElementById('adminControls').classList.remove('hidden');
closeLoginModal();
@@ -1017,7 +1028,8 @@ async function doLogin() {
}
async function logout() {
await fetch('api/auth.php?action=logout', { method: 'POST' });
await csrfFetch('api/auth.php?action=logout', { method: 'POST' });
csrfToken = '';
isAdmin = false;
document.getElementById('adminControls').classList.add('hidden');
await loadProjects();
@@ -1069,6 +1081,8 @@ function renderProjects() {
grid.innerHTML = pageProjects.map(p => {
const images = (p.images && p.images.length > 0) ? p.images : (p.image ? [p.image] : []);
const hasMultiple = images.length > 1;
const safeLink = sanitizeUrl(p.link);
const safeVideoUrl = sanitizeUrl(p.video_url);
const cardId = `card-${p.id}`;
return `
@@ -1087,7 +1101,7 @@ function renderProjects() {
onmouseleave="resumeSlideshow('${cardId}')">
${images.map((img, i) => `
<div class="slideshow-slide ${i === 0 ? 'active' : ''}"
style="background-image:url('${escapeHtml(img)}')"></div>
style="background-image:url('${escapeHtml(sanitizeUrl(img))}')"></div>
`).join('')}
${hasMultiple ? `
<button class="slideshow-arrow prev" onclick="slideshowPrev('${cardId}')" aria-label="이전">
@@ -1125,10 +1139,10 @@ function renderProjects() {
</div>
` : ''}
<div class="card-actions">
${p.link ? `<a href="${escapeHtml(p.link)}" class="btn-link git" target="_blank">
${safeLink ? `<a href="${escapeHtml(safeLink)}" class="btn-link git" target="_blank" rel="noopener noreferrer">
<i class="fa-brands fa-git-alt"></i><span class="btn-text"> 소스 코드</span>
</a>` : ''}
${p.video_url ? `<a href="${escapeHtml(p.video_url)}" class="btn-link video" target="_blank">
${safeVideoUrl ? `<a href="${escapeHtml(safeVideoUrl)}" class="btn-link video" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-circle-play"></i><span class="btn-text"> 영상 보기</span>
</a>` : ''}
</div>
@@ -1734,7 +1748,7 @@ async function saveProject() {
const formData = new FormData();
formData.append('file', item.file);
formData.append('project_title', title);
const res = await fetch('api/upload.php', { method: 'POST', body: formData });
const res = await csrfFetch('api/upload.php', { method: 'POST', body: formData });
const result = await res.json();
if (!result.success) throw new Error(result.error || '이미지 업로드 실패');
URL.revokeObjectURL(item.url); // 메모리 해제
@@ -1753,7 +1767,7 @@ async function saveProject() {
const formData = new FormData();
formData.append('file', pendingVideoFile);
formData.append('project_title', title);
const res = await fetch('api/upload.php', { method: 'POST', body: formData });
const res = await csrfFetch('api/upload.php', { method: 'POST', body: formData });
const result = await res.json();
if (!result.success) throw new Error(result.error || '영상 업로드 실패');
videoUrl = result.url;
@@ -1784,13 +1798,13 @@ async function saveProject() {
let res;
if (id) {
data.id = parseInt(id);
res = await fetch('api/projects.php', {
res = await csrfFetch('api/projects.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
res = await fetch('api/projects.php', {
res = await csrfFetch('api/projects.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
@@ -1849,7 +1863,7 @@ async function deleteProject(id) {
}
try {
const res = await fetch(`api/projects.php?id=${id}`, { method: 'DELETE' });
const res = await csrfFetch(`api/projects.php?id=${id}`, { method: 'DELETE' });
const result = await res.json();
if (!result.success) {
alert(result.error || '삭제 실패');
@@ -1858,16 +1872,14 @@ async function deleteProject(id) {
if (deleteFolder && folderName) {
try {
const fileRes = await fetch('api/delete_files.php', {
const fileRes = await csrfFetch('api/delete_files.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder: folderName })
});
const fileResult = await fileRes.json();
if (fileResult.success) {
if (fileResult.deleted_count > 0) {
console.log(`이미지 폴더 정리: ${fileResult.deleted_count}개 파일 삭제됨`);
}
if (!fileResult.success || fileResult.failed_count > 0) {
alert('프로젝트는 삭제되었지만, 일부 파일 삭제에 실패했습니다.');
}
} catch (e) {
alert('프로젝트는 삭제되었지만, 폴더 삭제 중 오류: ' + e.message);
@@ -1896,6 +1908,18 @@ function escapeHtml(str) {
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
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') {