Harden admin security controls
This commit is contained in:
+38
-14
@@ -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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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') {
|
||||
|
||||
Reference in New Issue
Block a user