Harden admin security controls
This commit is contained in:
+13
-3
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/error_config.php';
|
||||
session_start();
|
||||
start_secure_session();
|
||||
set_json_headers();
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
@@ -21,12 +21,16 @@ if ($method === 'POST' && $action === 'login') {
|
||||
// 무차별 대입 방지 - 간단한 딜레이
|
||||
usleep(500000); // 0.5초
|
||||
|
||||
if (ADMIN_PASSWORD_HASH === '') {
|
||||
json_response(['error' => 'Admin password is not configured'], 500);
|
||||
}
|
||||
|
||||
if (password_verify($password, ADMIN_PASSWORD_HASH)) {
|
||||
// 세션 고정 공격 방지
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['authenticated'] = true;
|
||||
$_SESSION['login_time'] = time();
|
||||
json_response(['success' => true, 'message' => '로그인 성공']);
|
||||
json_response(['success' => true, 'message' => '로그인 성공', 'csrf_token' => ensure_csrf_token()]);
|
||||
} else {
|
||||
json_response(['error' => '비밀번호가 일치하지 않습니다'], 401);
|
||||
}
|
||||
@@ -36,6 +40,8 @@ if ($method === 'POST' && $action === 'login') {
|
||||
// 로그아웃
|
||||
// =====================================================
|
||||
if ($method === 'POST' && $action === 'logout') {
|
||||
require_auth();
|
||||
require_csrf();
|
||||
session_destroy();
|
||||
json_response(['success' => true]);
|
||||
}
|
||||
@@ -54,7 +60,11 @@ if ($method === 'GET' && $action === 'check') {
|
||||
}
|
||||
}
|
||||
|
||||
json_response(['authenticated' => $authenticated]);
|
||||
$response = ['authenticated' => $authenticated];
|
||||
if ($authenticated) {
|
||||
$response['csrf_token'] = ensure_csrf_token();
|
||||
}
|
||||
json_response($response);
|
||||
}
|
||||
|
||||
json_response(['error' => 'Invalid action'], 400);
|
||||
|
||||
+5
-4
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/error_config.php';
|
||||
session_start();
|
||||
start_secure_session();
|
||||
set_json_headers();
|
||||
|
||||
define('CATEGORIES_FILE', DATA_DIR . '/categories.json');
|
||||
@@ -12,7 +12,7 @@ $method = $_SERVER['REQUEST_METHOD'];
|
||||
function normalize_category($cat) {
|
||||
if (!isset($cat['parent_id'])) $cat['parent_id'] = null;
|
||||
if (!isset($cat['order'])) $cat['order'] = 0;
|
||||
if (!isset($cat['color'])) $cat['color'] = '#00f2ff';
|
||||
$cat['color'] = clean_css_color($cat['color'] ?? '#00f2ff');
|
||||
return $cat;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ if ($method === 'GET') {
|
||||
}
|
||||
|
||||
require_auth();
|
||||
require_csrf();
|
||||
|
||||
// =====================================================
|
||||
// POST: 카테고리 추가
|
||||
@@ -42,7 +43,7 @@ require_auth();
|
||||
if ($method === 'POST') {
|
||||
$input = get_json_input();
|
||||
$name = trim($input['name'] ?? '');
|
||||
$color = trim($input['color'] ?? '#00f2ff');
|
||||
$color = clean_css_color($input['color'] ?? '#00f2ff');
|
||||
$parentId = isset($input['parent_id']) && $input['parent_id'] !== '' && $input['parent_id'] !== null
|
||||
? intval($input['parent_id'])
|
||||
: null;
|
||||
@@ -115,7 +116,7 @@ if ($method === 'PUT') {
|
||||
foreach ($categories as $key => $cat) {
|
||||
if ($cat['id'] === $id) {
|
||||
if (isset($input['name'])) $categories[$key]['name'] = trim($input['name']);
|
||||
if (isset($input['color'])) $categories[$key]['color'] = trim($input['color']);
|
||||
if (isset($input['color'])) $categories[$key]['color'] = clean_css_color($input['color']);
|
||||
if (isset($input['order'])) $categories[$key]['order'] = intval($input['order']);
|
||||
// parent_id 변경은 허용하되, 자식이 있는 경우 자식으로 만들지 못하게
|
||||
if (array_key_exists('parent_id', $input)) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
// Copy this file to api/config.local.php on the server and replace the value.
|
||||
// Generate a hash with generate_password.php or:
|
||||
// php -r "echo password_hash('your-strong-password', PASSWORD_DEFAULT);"
|
||||
define('ADMIN_PASSWORD_HASH', '$2y$10$replace_with_your_generated_hash');
|
||||
+102
-38
@@ -1,39 +1,57 @@
|
||||
<?php
|
||||
// =====================================================
|
||||
// 설정 파일
|
||||
// Runtime configuration
|
||||
// =====================================================
|
||||
// ⚠️ 중요: 처음 사용 시 아래 ADMIN_PASSWORD_HASH를 변경하세요
|
||||
// 비밀번호 해시 생성 방법:
|
||||
// 브라우저에서 generate_password.php 접속 후 원하는 비번 입력
|
||||
// 또는 터미널에서: php -r "echo password_hash('your_password', PASSWORD_DEFAULT);"
|
||||
// ADMIN_PASSWORD_HASH is loaded from api/config.local.php or the environment.
|
||||
// Do not commit real password hashes or other secrets to the repository.
|
||||
// =====================================================
|
||||
|
||||
// 기본 비밀번호: "admin1234" (반드시 변경하세요!)
|
||||
define('ADMIN_PASSWORD_HASH', '$2y$10$Wj/5fxQX90AlvyVPBfE0te2aUbysSBlE/Umm7EluG880rqcRUlHGm');
|
||||
$localConfig = __DIR__ . '/config.local.php';
|
||||
if (is_file($localConfig)) {
|
||||
require_once $localConfig;
|
||||
}
|
||||
|
||||
// 데이터 파일 경로
|
||||
if (!defined('ADMIN_PASSWORD_HASH')) {
|
||||
$adminPasswordHash = getenv('ADMIN_PASSWORD_HASH');
|
||||
define('ADMIN_PASSWORD_HASH', is_string($adminPasswordHash) ? trim($adminPasswordHash) : '');
|
||||
}
|
||||
|
||||
// Data paths
|
||||
define('DATA_DIR', __DIR__ . '/../data');
|
||||
define('UPLOADS_DIR', __DIR__ . '/../uploads');
|
||||
define('PROJECTS_FILE', DATA_DIR . '/projects.json');
|
||||
define('PROFILE_FILE', DATA_DIR . '/profile.json');
|
||||
|
||||
// 세션 설정
|
||||
define('SESSION_LIFETIME', 3600 * 4); // 4시간
|
||||
// Session settings
|
||||
define('SESSION_LIFETIME', 3600 * 4);
|
||||
|
||||
function start_secure_session() {
|
||||
if (session_status() !== PHP_SESSION_NONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
// CORS 및 JSON 헤더
|
||||
function set_json_headers() {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
}
|
||||
|
||||
// JSON 파일을 락 걸고 읽기
|
||||
function read_json_safe($filepath) {
|
||||
if (!file_exists($filepath)) {
|
||||
return null;
|
||||
}
|
||||
$fp = fopen($filepath, 'r');
|
||||
if (!$fp) return null;
|
||||
|
||||
|
||||
if (flock($fp, LOCK_SH)) {
|
||||
$content = stream_get_contents($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
@@ -44,41 +62,33 @@ function read_json_safe($filepath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// JSON 파일을 락 걸고 쓰기
|
||||
// Write JSON atomically so a crash during write does not leave a 0-byte file.
|
||||
function write_json_safe($filepath, $data) {
|
||||
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) return false;
|
||||
|
||||
$fp = fopen($filepath, 'c+');
|
||||
if (!$fp) return false;
|
||||
|
||||
if (flock($fp, LOCK_EX)) {
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, $json);
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
return true;
|
||||
|
||||
$tmp = $filepath . '.tmp.' . uniqid('', true);
|
||||
if (file_put_contents($tmp, $json, LOCK_EX) === false) {
|
||||
@unlink($tmp);
|
||||
return false;
|
||||
}
|
||||
fclose($fp);
|
||||
return false;
|
||||
if (!rename($tmp, $filepath)) {
|
||||
@unlink($tmp);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 인증 체크
|
||||
function require_auth() {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
start_secure_session();
|
||||
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 세션 타임아웃 체크
|
||||
if (isset($_SESSION['login_time']) &&
|
||||
|
||||
if (isset($_SESSION['login_time']) &&
|
||||
(time() - $_SESSION['login_time']) > SESSION_LIFETIME) {
|
||||
session_destroy();
|
||||
http_response_code(401);
|
||||
@@ -87,13 +97,67 @@ function require_auth() {
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 입력 받기
|
||||
function ensure_csrf_token() {
|
||||
start_secure_session();
|
||||
if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
function require_csrf() {
|
||||
start_secure_session();
|
||||
$sessionToken = $_SESSION['csrf_token'] ?? '';
|
||||
$requestToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
|
||||
if (!is_string($sessionToken) || $sessionToken === '' ||
|
||||
!is_string($requestToken) || !hash_equals($sessionToken, $requestToken)) {
|
||||
json_response(['error' => 'Invalid CSRF token'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
function get_json_input() {
|
||||
$input = file_get_contents('php://input');
|
||||
return json_decode($input, true);
|
||||
}
|
||||
|
||||
// 응답 헬퍼
|
||||
function is_safe_public_url($url) {
|
||||
if (!is_string($url)) return false;
|
||||
$url = trim($url);
|
||||
if ($url === '') return true;
|
||||
|
||||
if (preg_match('#^uploads/[A-Za-z0-9가-힣._~!$&\'()*+,;=:@%/-]+$#u', $url)) {
|
||||
return !str_contains($url, '..') && !preg_match('#(^|/)\.#', $url);
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
if ($parts === false || empty($parts['scheme'])) return false;
|
||||
return in_array(strtolower($parts['scheme']), ['http', 'https'], true);
|
||||
}
|
||||
|
||||
function clean_public_url($url) {
|
||||
$url = is_string($url) ? trim($url) : '';
|
||||
return is_safe_public_url($url) ? $url : '';
|
||||
}
|
||||
|
||||
function clean_public_urls($urls) {
|
||||
if (!is_array($urls)) return [];
|
||||
$clean = [];
|
||||
foreach ($urls as $url) {
|
||||
$url = clean_public_url($url);
|
||||
if ($url !== '') $clean[] = $url;
|
||||
}
|
||||
return array_values(array_unique($clean));
|
||||
}
|
||||
|
||||
function clean_css_color($value, $fallback = '#00f2ff') {
|
||||
$value = is_string($value) ? trim($value) : '';
|
||||
if (preg_match('/^#[0-9a-fA-F]{6}$/', $value) || preg_match('/^#[0-9a-fA-F]{3}$/', $value)) {
|
||||
return $value;
|
||||
}
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
function json_response($data, $status = 200) {
|
||||
http_response_code($status);
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/error_config.php';
|
||||
session_start();
|
||||
start_secure_session();
|
||||
set_json_headers();
|
||||
require_auth();
|
||||
require_csrf();
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
|
||||
+22
-3
@@ -1,13 +1,31 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/error_config.php';
|
||||
session_start();
|
||||
start_secure_session();
|
||||
set_json_headers();
|
||||
|
||||
define('LEARNING_FILE', DATA_DIR . '/learning.json');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
function clean_learning_content($content) {
|
||||
$content = is_string($content) ? trim($content) : '';
|
||||
$content = preg_replace_callback('/\]\(([^)]+)\)/', function($m) {
|
||||
$url = trim($m[1]);
|
||||
return is_safe_public_url($url) ? '](' . $url . ')' : '](#)';
|
||||
}, $content);
|
||||
$content = preg_replace_callback('/@video(\[[^\]]*\])?\(([^)]+)\)/', function($m) {
|
||||
$attrs = $m[1] ?? '';
|
||||
$url = trim($m[2]);
|
||||
return is_safe_public_url($url) ? '@video' . $attrs . '(' . $url . ')' : '';
|
||||
}, $content);
|
||||
$content = preg_replace_callback('/\{color:([^}]+)\}([\s\S]+?)\{\/color\}/', function($m) {
|
||||
$color = clean_css_color($m[1], '');
|
||||
return $color === '' ? $m[2] : '{color:' . $color . '}' . $m[2] . '{/color}';
|
||||
}, $content);
|
||||
return $content;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// GET: 학습 일지 목록 또는 단일 글 (인증 불필요)
|
||||
// =====================================================
|
||||
@@ -63,6 +81,7 @@ if ($method === 'GET') {
|
||||
}
|
||||
|
||||
require_auth();
|
||||
require_csrf();
|
||||
|
||||
// =====================================================
|
||||
// POST: 새 학습 일지 작성
|
||||
@@ -71,7 +90,7 @@ if ($method === 'POST') {
|
||||
$input = get_json_input();
|
||||
|
||||
$title = trim($input['title'] ?? '');
|
||||
$content = trim($input['content'] ?? '');
|
||||
$content = clean_learning_content($input['content'] ?? '');
|
||||
$categoryId = intval($input['category_id'] ?? 0);
|
||||
|
||||
if (empty($title) || empty($content)) {
|
||||
@@ -170,7 +189,7 @@ if ($method === 'PUT') {
|
||||
$learnings[$key]['title'] = $title;
|
||||
}
|
||||
if (isset($input['content'])) {
|
||||
$content = trim($input['content']);
|
||||
$content = clean_learning_content($input['content']);
|
||||
if ($content === '') json_response(['error' => '내용은 비울 수 없습니다'], 400);
|
||||
$learnings[$key]['content'] = $content;
|
||||
}
|
||||
|
||||
+10
-3
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/error_config.php';
|
||||
session_start();
|
||||
start_secure_session();
|
||||
set_json_headers();
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
@@ -49,6 +49,7 @@ if ($method === 'GET') {
|
||||
|
||||
// 이하 수정은 인증 필요
|
||||
require_auth();
|
||||
require_csrf();
|
||||
|
||||
// =====================================================
|
||||
// PUT: 프로필 전체 업데이트
|
||||
@@ -66,7 +67,7 @@ if ($method === 'PUT') {
|
||||
'name' => trim($input['name'] ?? $current['name'] ?? ''),
|
||||
'title' => trim($input['title'] ?? $current['title'] ?? ''),
|
||||
'tagline' => trim($input['tagline'] ?? $current['tagline'] ?? ''),
|
||||
'avatar' => trim($input['avatar'] ?? $current['avatar'] ?? ''),
|
||||
'avatar' => clean_public_url($input['avatar'] ?? $current['avatar'] ?? ''),
|
||||
'bio' => trim($input['bio'] ?? $current['bio'] ?? ''),
|
||||
'location' => trim($input['location'] ?? $current['location'] ?? ''),
|
||||
'email' => trim($input['email'] ?? $current['email'] ?? ''),
|
||||
@@ -101,7 +102,13 @@ if ($method === 'PUT') {
|
||||
}
|
||||
|
||||
if (isset($input['social']) && is_array($input['social'])) {
|
||||
$updated['social'] = array_merge($current['social'] ?? [], $input['social']);
|
||||
$social = array_merge($current['social'] ?? [], $input['social']);
|
||||
foreach (['github', 'linkedin', 'blog'] as $key) {
|
||||
if (isset($social[$key])) {
|
||||
$social[$key] = clean_public_url($social[$key]);
|
||||
}
|
||||
}
|
||||
$updated['social'] = $social;
|
||||
}
|
||||
|
||||
if (write_json_safe(PROFILE_FILE, $updated)) {
|
||||
|
||||
+9
-11
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/error_config.php';
|
||||
session_start();
|
||||
start_secure_session();
|
||||
set_json_headers();
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
@@ -11,14 +11,11 @@ $method = $_SERVER['REQUEST_METHOD'];
|
||||
// =====================================================
|
||||
function normalize_images($input) {
|
||||
if (isset($input['images']) && is_array($input['images'])) {
|
||||
$images = array_values(array_filter(
|
||||
array_map('trim', $input['images']),
|
||||
fn($v) => $v !== ''
|
||||
));
|
||||
return $images;
|
||||
return clean_public_urls($input['images']);
|
||||
}
|
||||
if (isset($input['image']) && trim($input['image']) !== '') {
|
||||
return [trim($input['image'])];
|
||||
$image = clean_public_url($input['image']);
|
||||
return $image === '' ? [] : [$image];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -80,6 +77,7 @@ if ($method === 'GET') {
|
||||
}
|
||||
|
||||
require_auth();
|
||||
require_csrf();
|
||||
|
||||
// =====================================================
|
||||
// POST: 새 프로젝트 추가
|
||||
@@ -113,11 +111,11 @@ if ($method === 'POST') {
|
||||
'icon' => trim($input['icon'] ?? 'fa-solid fa-code'),
|
||||
'images' => $images,
|
||||
'image' => $images[0] ?? '',
|
||||
'link' => trim($input['link'] ?? ''),
|
||||
'link' => clean_public_url($input['link'] ?? ''),
|
||||
'stack' => $stack,
|
||||
'period_start' => trim($input['period_start'] ?? ''),
|
||||
'period_end' => trim($input['period_end'] ?? ''),
|
||||
'video_url' => trim($input['video_url'] ?? ''),
|
||||
'video_url' => clean_public_url($input['video_url'] ?? ''),
|
||||
'created_at' => date('Y-m-d')
|
||||
];
|
||||
|
||||
@@ -150,7 +148,7 @@ if ($method === 'PUT') {
|
||||
$projects[$key]['label'] = trim($input['label'] ?? $project['label']);
|
||||
$projects[$key]['description'] = trim($input['description'] ?? $project['description']);
|
||||
$projects[$key]['icon'] = trim($input['icon'] ?? $project['icon']);
|
||||
$projects[$key]['link'] = trim($input['link'] ?? $project['link']);
|
||||
$projects[$key]['link'] = clean_public_url($input['link'] ?? $project['link']);
|
||||
|
||||
if (isset($input['images']) || isset($input['image'])) {
|
||||
$images = normalize_images($input);
|
||||
@@ -169,7 +167,7 @@ if ($method === 'PUT') {
|
||||
$projects[$key]['period_end'] = trim($input['period_end']);
|
||||
}
|
||||
if (isset($input['video_url'])) {
|
||||
$projects[$key]['video_url'] = trim($input['video_url']);
|
||||
$projects[$key]['video_url'] = clean_public_url($input['video_url']);
|
||||
}
|
||||
|
||||
// 기존 demo_url 필드 제거
|
||||
|
||||
+8
-2
@@ -1,9 +1,10 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/error_config.php';
|
||||
session_start();
|
||||
start_secure_session();
|
||||
set_json_headers();
|
||||
require_auth();
|
||||
require_csrf();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
json_response(['error' => 'POST만 허용됩니다'], 405);
|
||||
@@ -225,5 +226,10 @@ if ($saved) {
|
||||
]);
|
||||
} else {
|
||||
$err = error_get_last();
|
||||
json_response(['error' => '파일 저장 실패', 'detail' => $err, 'target' => $target_path, 'tmp' => $file['tmp_name'], 'tmp_exists' => file_exists($file['tmp_name'])], 500);
|
||||
error_log('Upload save failed: ' . json_encode([
|
||||
'detail' => $err,
|
||||
'target' => $target_path,
|
||||
'tmp_exists' => file_exists($file['tmp_name'])
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
json_response(['error' => '파일 저장 실패'], 500);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user