Harden admin security controls
This commit is contained in:
+110
-41
@@ -1585,7 +1585,7 @@ footer {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>색상</label>
|
||||
<input type="color" id="categoryColor" value="var(--primary)" style="height: 44px;">
|
||||
<input type="color" id="categoryColor" value="#00f2ff" style="height: 44px;">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" onclick="saveCategory()">
|
||||
@@ -1981,6 +1981,7 @@ function toggleTheme() {
|
||||
applyTheme(saved || prefer);
|
||||
})();
|
||||
let isAdmin = false;
|
||||
let csrfToken = '';
|
||||
let categories = []; // 모든 카테고리 (parent + child)
|
||||
let posts = [];
|
||||
let currentFilter = { type: 'all', value: null };
|
||||
@@ -2013,10 +2014,7 @@ function renderMarkdown(src) {
|
||||
parts.forEach(p => {
|
||||
const m = p.match(/^w(?:idth)?\s*=\s*(.+)$/i);
|
||||
if (m) {
|
||||
let w = m[1].trim();
|
||||
// 숫자만 있으면 px 추가
|
||||
if (/^\d+$/.test(w)) w = w + 'px';
|
||||
result.width = w;
|
||||
result.width = sanitizeMediaWidth(m[1].trim());
|
||||
return;
|
||||
}
|
||||
if (/^(left|center|right)$/i.test(p)) {
|
||||
@@ -2040,6 +2038,8 @@ function renderMarkdown(src) {
|
||||
src = src.replace(
|
||||
/@video(?:\[([^\]]*)\])?\(([^)]+)\)/g,
|
||||
function(match, attrStr, url) {
|
||||
const safeUrl = sanitizeUrl(url);
|
||||
if (!safeUrl) return '';
|
||||
const attrs = parseAttrs(attrStr || '');
|
||||
// width와 align은 wrap에 적용 (video 자체가 아님)
|
||||
const styles = [];
|
||||
@@ -2048,7 +2048,7 @@ function renderMarkdown(src) {
|
||||
if (alignS) styles.push(alignS.replace(/;$/, ''));
|
||||
styles.push('max-width:100%');
|
||||
const wrapStyle = `style="${styles.join(';')}"`;
|
||||
return `<div class="md-video-wrap" ${wrapStyle}><video class="md-video" preload="metadata" src="${url.trim()}"></video></div>`;
|
||||
return `<div class="md-video-wrap" ${wrapStyle}><video class="md-video" preload="metadata" src="${escapeHtml(safeUrl)}"></video></div>`;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2057,13 +2057,15 @@ function renderMarkdown(src) {
|
||||
src = src.replace(
|
||||
/!\[([^\]]*?)\|([^\]]+)\]\(([^)]+)\)/g,
|
||||
function(match, alt, attrStr, url) {
|
||||
const safeUrl = sanitizeUrl(url);
|
||||
if (!safeUrl) return '';
|
||||
const attrs = parseAttrs(attrStr);
|
||||
const styles = [];
|
||||
if (attrs.width) styles.push(`width:${attrs.width}`);
|
||||
styles.push('max-width:100%');
|
||||
const alignS = alignStyle(attrs.align);
|
||||
if (alignS) styles.push(alignS.replace(/;$/, ''));
|
||||
return `<img src="${url.trim()}" alt="${alt}" style="${styles.join(';')};">`;
|
||||
return `<img src="${escapeHtml(safeUrl)}" alt="${escapeHtml(alt)}" style="${styles.join(';')};">`;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2091,10 +2093,12 @@ function renderMarkdown(src) {
|
||||
src = src.replace(/==([^=\n\[]+)==/g, '<mark>$1</mark>');
|
||||
|
||||
// ===== 5) 색상: {color:#ff0000}text{/color} =====
|
||||
src = src.replace(/\{color:(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)\}([\s\S]+?)\{\/color\}/g,
|
||||
'<span class="md-color" style="color:$1">$2</span>');
|
||||
src = src.replace(/\{color:([^}]+)\}([\s\S]+?)\{\/color\}/g, function(match, color, text) {
|
||||
const safeColor = sanitizeCssColor(color);
|
||||
return safeColor ? `<span class="md-color" style="color:${safeColor}">${text}</span>` : text;
|
||||
});
|
||||
|
||||
return DOMPurify.sanitize(marked.parse(src));
|
||||
return sanitizeAllowedStyles(DOMPurify.sanitize(marked.parse(src)));
|
||||
}
|
||||
|
||||
// 렌더 후 비디오에 커스텀 컨트롤 부착
|
||||
@@ -2406,8 +2410,7 @@ function addMediaToolbar(wrap, kind, curWidth, curAlign) {
|
||||
}
|
||||
|
||||
function applyWidth(wrap, widthVal, kind) {
|
||||
// 숫자만 입력되면 px 추가
|
||||
if (/^\d+$/.test(widthVal)) widthVal = widthVal + 'px';
|
||||
widthVal = sanitizeMediaWidth(widthVal);
|
||||
wrap.style.width = widthVal || '';
|
||||
// 툴바 input 동기화
|
||||
const input = wrap.querySelector('.md-toolbar-size-input');
|
||||
@@ -2486,10 +2489,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.body.classList.toggle('admin-on', 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');
|
||||
@@ -2511,6 +2523,7 @@ async function doLogin() {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
csrfToken = data.csrf_token || csrfToken;
|
||||
isAdmin = true;
|
||||
document.body.classList.add('admin-on');
|
||||
closeLoginModal();
|
||||
@@ -2522,7 +2535,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.body.classList.remove('admin-on');
|
||||
}
|
||||
@@ -2746,7 +2760,7 @@ function renderCategoryList() {
|
||||
onclick="onParentClick(${parent.id})">
|
||||
<div class="cat-name">
|
||||
<i class="fa-solid fa-chevron-right cat-toggle"></i>
|
||||
<span class="cat-dot" style="background: ${escapeHtml(parent.color)};"></span>
|
||||
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(parent.color, '#00f2ff'))};"></span>
|
||||
<span class="cat-label">${escapeHtml(parent.name)}</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
@@ -2781,7 +2795,7 @@ function renderCategoryList() {
|
||||
<div class="cat-item ${isActive ? 'active' : ''}"
|
||||
onclick="setFilter('category', ${child.id})">
|
||||
<div class="cat-name">
|
||||
<span class="cat-dot" style="background: ${escapeHtml(child.color)};"></span>
|
||||
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(child.color, '#00f2ff'))};"></span>
|
||||
<span class="cat-label">${escapeHtml(child.name)}</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
@@ -2909,10 +2923,6 @@ function renderPosts() {
|
||||
const postDate = (p.created_at || '').trim();
|
||||
return postDate === dateFilter;
|
||||
});
|
||||
// 디버그: 일치하는 글 없으면 콘솔에 출력
|
||||
if (filtered.length === 0) {
|
||||
console.log('[날짜 필터]', dateFilter, '- 전체 글의 날짜:', posts.map(p => p.created_at));
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 (scope에 따라 분기)
|
||||
@@ -2959,7 +2969,7 @@ function renderPosts() {
|
||||
list.innerHTML = filtered.map(p => {
|
||||
const cat = getCategoryById(p.category_id);
|
||||
const parent = cat ? getCategoryById(cat.parent_id) : null;
|
||||
const catColor = cat ? cat.color : 'var(--primary)';
|
||||
const catColor = cat ? sanitizeCssColor(cat.color, '#00f2ff') : '#00f2ff';
|
||||
const preview = stripMarkdown(p.content || '').slice(0, 200);
|
||||
|
||||
return `
|
||||
@@ -2972,7 +2982,7 @@ function renderPosts() {
|
||||
</div>
|
||||
${cat ? `
|
||||
<div class="post-cat-pill">
|
||||
<span class="cat-dot" style="background: ${escapeHtml(cat.color)};"></span>
|
||||
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(cat.color, '#00f2ff'))};"></span>
|
||||
${parent ? `<span style="opacity: 0.7;">${escapeHtml(parent.name)} ›</span>` : ''}
|
||||
${escapeHtml(cat.name)}
|
||||
</div>
|
||||
@@ -3012,7 +3022,7 @@ function openPostDetail(id) {
|
||||
if (cat) {
|
||||
metaParts.push(`
|
||||
<div class="post-cat-pill">
|
||||
<span class="cat-dot" style="background: ${escapeHtml(cat.color)};"></span>
|
||||
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(cat.color, '#00f2ff'))};"></span>
|
||||
${parent ? `<span style="opacity: 0.7;">${escapeHtml(parent.name)} ›</span>` : ''}
|
||||
${escapeHtml(cat.name)}
|
||||
</div>
|
||||
@@ -3109,7 +3119,7 @@ async function deleteCurrentPost() {
|
||||
|
||||
try {
|
||||
// 글 삭제
|
||||
const res = await fetch(`api/learning.php?id=${currentDetailId}`, { method: 'DELETE' });
|
||||
const res = await csrfFetch(`api/learning.php?id=${currentDetailId}`, { method: 'DELETE' });
|
||||
const result = await res.json();
|
||||
if (!result.success) {
|
||||
alert(result.error || '글 삭제 실패');
|
||||
@@ -3119,16 +3129,13 @@ async function deleteCurrentPost() {
|
||||
// 첨부 파일 삭제 (선택했을 때)
|
||||
if (deleteFiles && attachedUrls.length > 0) {
|
||||
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({ urls: attachedUrls })
|
||||
});
|
||||
const fileResult = await fileRes.json();
|
||||
if (fileResult.success) {
|
||||
if (fileResult.deleted_count > 0) {
|
||||
console.log(`첨부 파일 ${fileResult.deleted_count}개 삭제됨`);
|
||||
}
|
||||
if (fileResult.failed_count > 0) {
|
||||
alert(`글은 삭제되었지만, ${fileResult.failed_count}개 파일 삭제에 실패했습니다.`);
|
||||
}
|
||||
@@ -3523,9 +3530,9 @@ function buildMediaAttrs() {
|
||||
const parts = [];
|
||||
let width = '';
|
||||
if (widthSel === 'custom') {
|
||||
width = widthCust;
|
||||
width = sanitizeMediaWidth(widthCust);
|
||||
} else if (widthSel) {
|
||||
width = widthSel;
|
||||
width = sanitizeMediaWidth(widthSel);
|
||||
}
|
||||
if (width) parts.push(`w=${width}`);
|
||||
if (align) parts.push(align);
|
||||
@@ -3568,11 +3575,11 @@ function insertAttachFile() {
|
||||
}
|
||||
|
||||
function insertMediaFromUrl() {
|
||||
const url = document.getElementById('imageInsertUrl').value.trim();
|
||||
const url = sanitizeUrl(document.getElementById('imageInsertUrl').value.trim());
|
||||
const alt = document.getElementById('imageInsertAlt').value.trim();
|
||||
const kind = document.getElementById('imageInsertKind').value;
|
||||
if (!url) {
|
||||
showAlert('imageInsertAlert', 'URL을 입력해주세요', 'error');
|
||||
showAlert('imageInsertAlert', 'http, https, uploads/ 경로만 사용할 수 있습니다', 'error');
|
||||
return;
|
||||
}
|
||||
insertAtCursor(buildMediaMarkdown(kind, url, alt));
|
||||
@@ -3667,7 +3674,7 @@ async function uploadPendingMedia(content) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('project_title', 'learning');
|
||||
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 && result.url) {
|
||||
@@ -3699,7 +3706,7 @@ async function uploadPendingMedia(content) {
|
||||
// ===== 색상 선택 =====
|
||||
const COLOR_PALETTE = [
|
||||
// 세이지/민트 계열
|
||||
'var(--primary)', '#3d6b50', '#8ab89a', '#B2E2D2',
|
||||
'#00f2ff', '#3d6b50', '#8ab89a', '#B2E2D2',
|
||||
// 웜 뉴트럴
|
||||
'#b07a20', '#c9a050', '#c8856a', '#9e6b52',
|
||||
// 레드/핑크 (핀포인트)
|
||||
@@ -3751,7 +3758,8 @@ function closeColorPicker() {
|
||||
document.getElementById('colorPickerModal').classList.remove('active');
|
||||
}
|
||||
function applyColor(color) {
|
||||
wrapSelection(`{color:${color}}`, '{/color}', '');
|
||||
const safeColor = sanitizeCssColor(color);
|
||||
if (safeColor) wrapSelection(`{color:${safeColor}}`, '{/color}', '');
|
||||
closeColorPicker();
|
||||
}
|
||||
|
||||
@@ -3847,13 +3855,13 @@ async function savePost() {
|
||||
let res;
|
||||
if (id) {
|
||||
data.id = parseInt(id);
|
||||
res = await fetch('api/learning.php', {
|
||||
res = await csrfFetch('api/learning.php', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} else {
|
||||
res = await fetch('api/learning.php', {
|
||||
res = await csrfFetch('api/learning.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
@@ -3900,7 +3908,7 @@ function openCategoryModal(cat = null) {
|
||||
document.getElementById('categoryModalTitle').textContent = '카테고리 수정';
|
||||
document.getElementById('categoryId').value = cat.id;
|
||||
document.getElementById('categoryName').value = cat.name;
|
||||
document.getElementById('categoryColor').value = cat.color || 'var(--primary)';
|
||||
document.getElementById('categoryColor').value = sanitizeCssColor(cat.color, '#00f2ff');
|
||||
parentSel.value = cat.parent_id || '';
|
||||
// 자식이 있으면 부모 변경 비활성화
|
||||
const hasChildren = categories.some(c => c.parent_id === cat.id);
|
||||
@@ -3912,7 +3920,7 @@ function openCategoryModal(cat = null) {
|
||||
document.getElementById('categoryModalTitle').textContent = '카테고리 추가';
|
||||
document.getElementById('categoryId').value = '';
|
||||
document.getElementById('categoryName').value = '';
|
||||
document.getElementById('categoryColor').value = 'var(--primary)';
|
||||
document.getElementById('categoryColor').value = '#00f2ff';
|
||||
parentSel.value = '';
|
||||
parentSel.disabled = false;
|
||||
}
|
||||
@@ -3966,7 +3974,7 @@ async function deleteCategory(id) {
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`api/categories.php?id=${id}`, { method: 'DELETE' });
|
||||
const res = await csrfFetch(`api/categories.php?id=${id}`, { method: 'DELETE' });
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
// 현재 필터가 영향받으면 전체로
|
||||
@@ -4002,13 +4010,13 @@ async function saveCategory() {
|
||||
let res;
|
||||
if (id) {
|
||||
payload.id = parseInt(id);
|
||||
res = await fetch('api/categories.php', {
|
||||
res = await csrfFetch('api/categories.php', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} else {
|
||||
res = await fetch('api/categories.php', {
|
||||
res = await csrfFetch('api/categories.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
@@ -4049,6 +4057,67 @@ 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 '';
|
||||
}
|
||||
|
||||
function sanitizeCssColor(color, fallback = '') {
|
||||
const trimmed = String(color || '').trim();
|
||||
return /^#[0-9a-fA-F]{3}$/.test(trimmed) || /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed : fallback;
|
||||
}
|
||||
|
||||
function sanitizeMediaWidth(width) {
|
||||
let value = String(width || '').trim();
|
||||
if (/^\d+$/.test(value)) value += 'px';
|
||||
const px = value.match(/^(\d{1,4})px$/);
|
||||
if (px) return Math.min(Math.max(parseInt(px[1], 10), 40), 1200) + 'px';
|
||||
const pct = value.match(/^(\d{1,3})%$/);
|
||||
if (pct) return Math.min(Math.max(parseInt(pct[1], 10), 1), 100) + '%';
|
||||
return '';
|
||||
}
|
||||
|
||||
function sanitizeAllowedStyles(html) {
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = html;
|
||||
template.content.querySelectorAll('[style]').forEach(el => {
|
||||
const allowed = [];
|
||||
const declarations = String(el.getAttribute('style') || '').split(';');
|
||||
declarations.forEach(decl => {
|
||||
const [rawProp, ...rawValueParts] = decl.split(':');
|
||||
if (!rawProp || rawValueParts.length === 0) return;
|
||||
const prop = rawProp.trim().toLowerCase();
|
||||
const value = rawValueParts.join(':').trim();
|
||||
if (prop === 'width') {
|
||||
const width = sanitizeMediaWidth(value);
|
||||
if (width) allowed.push(`width:${width}`);
|
||||
} else if (prop === 'max-width' && value === '100%') {
|
||||
allowed.push('max-width:100%');
|
||||
} else if (prop === 'display' && value === 'block') {
|
||||
allowed.push('display:block');
|
||||
} else if ((prop === 'margin-left' || prop === 'margin-right') && /^(auto|0)$/.test(value)) {
|
||||
allowed.push(`${prop}:${value}`);
|
||||
} else if (prop === 'color') {
|
||||
const color = sanitizeCssColor(value);
|
||||
if (color) allowed.push(`color:${color}`);
|
||||
}
|
||||
});
|
||||
if (allowed.length > 0) {
|
||||
el.setAttribute('style', allowed.join(';'));
|
||||
} else {
|
||||
el.removeAttribute('style');
|
||||
}
|
||||
});
|
||||
return template.innerHTML;
|
||||
}
|
||||
|
||||
// ESC / 백스페이스로 모달 닫기
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
|
||||
Reference in New Issue
Block a user