diff --git a/.gitignore b/.gitignore index 901d4e9..122341f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .strideterm/ +api/config.local.php diff --git a/README.md b/README.md index 657b23d..9971cee 100644 --- a/README.md +++ b/README.md @@ -42,24 +42,36 @@ portfolio/ File Station에서 두 폴더의 권한을 `http` 그룹에 쓰기 가능하게 설정하세요. -### 4. 비밀번호 설정 (⚠️ 매우 중요) +### 4. 관리자 비밀번호와 보안 설정 -**(A) 해시 생성** -브라우저에서 접속: +실제 관리자 비밀번호 해시는 저장소에 커밋하지 않습니다. 운영 서버에서는 다음 둘 중 하나로 설정하세요. + +**(A) api/config.local.php 사용 권장** + +1. `api/config.local.example.php`를 `api/config.local.php`로 복사합니다. +2. 브라우저에서 다음 주소에 접속해 원하는 비밀번호의 해시를 생성합니다. ``` http://your-nas/portfolio/generate_password.php ``` -원하는 비밀번호를 입력하면 해시가 출력됩니다. +3. 생성된 해시를 `api/config.local.php`의 `ADMIN_PASSWORD_HASH` 값으로 넣습니다. +4. `api/config.local.php`는 `.gitignore`에 포함되어 있으므로 저장소에 올리지 않습니다. -**(B) config.php 수정** -`api/config.php` 파일을 열어 아래 줄을 찾고: -```php -define('ADMIN_PASSWORD_HASH', '$2y$10$YourHashWillGoHere...'); +**(B) 환경변수 사용** + +서버 환경변수 `ADMIN_PASSWORD_HASH`에 `password_hash()`로 만든 해시를 설정해도 됩니다. + +```bash +ADMIN_PASSWORD_HASH='$2y$10$replace_with_your_generated_hash' ``` -방금 생성된 해시로 교체합니다. -**(C) generate_password.php 삭제** -보안을 위해 반드시 삭제하세요! +`api/config.php`에는 기본 관리자 비밀번호나 실제 해시가 들어 있지 않아야 합니다. 설정이 없으면 관리자 로그인은 실패합니다. + +**추가 운영 보안** + +- 로그인 후 상태 변경 API는 CSRF 토큰을 요구합니다. 프론트엔드의 `fetch` 흐름은 로그인/세션 확인 응답에서 받은 토큰을 자동으로 보냅니다. +- 업로드 실패 응답에는 서버 내부 경로, 임시 경로, 저장 대상 경로가 노출되지 않습니다. 상세 오류는 서버 로그에서만 확인합니다. +- URL 입력은 `http`, `https`, `uploads/` 상대 경로만 허용합니다. CSS 색상 값은 hex 색상 allowlist로 제한합니다. +- 보안을 위해 `generate_password.php`는 설정 완료 후 운영 서버에서 삭제하거나 접근을 차단하세요. ### 5. 접속 - 메인: `http://your-nas/portfolio/` @@ -82,7 +94,8 @@ define('ADMIN_PASSWORD_HASH', '$2y$10$YourHashWillGoHere...'); ## 🔒 보안 체크리스트 - [ ] `generate_password.php` 삭제했는가? -- [ ] `config.php`의 ADMIN_PASSWORD_HASH를 실제 해시로 교체했는가? +- [ ] `api/config.local.php` 또는 `ADMIN_PASSWORD_HASH` 환경변수에 실제 관리자 해시가 설정되어 있는가? +- [ ] `api/config.local.php`가 저장소에 커밋되지 않는가? - [ ] HTTPS 설정 (NAS의 Reverse Proxy 또는 Let's Encrypt) - [ ] 비밀번호는 8자 이상, 영문/숫자/기호 조합 - [ ] `data/`, `uploads/` 폴더의 .htaccess 파일이 동작하는지 확인 @@ -107,7 +120,7 @@ git push -u origin main → `data/` 폴더의 쓰기 권한을 확인하세요. **로그인이 안 됨** -→ `config.php`의 ADMIN_PASSWORD_HASH가 올바르게 교체되었는지 확인하세요. +→ `api/config.local.php` 또는 `ADMIN_PASSWORD_HASH` 환경변수에 올바른 해시가 설정되어 있는지 확인하세요. **.htaccess가 동작하지 않음** → Web Station에서 Apache 사용 + `mod_rewrite`, `AllowOverride All` 설정 필요. diff --git a/api/auth.php b/api/auth.php index 5e0634a..9c47fe6 100644 --- a/api/auth.php +++ b/api/auth.php @@ -1,7 +1,7 @@ '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); diff --git a/api/categories.php b/api/categories.php index a6cf8e4..83e9916 100644 --- a/api/categories.php +++ b/api/categories.php @@ -1,7 +1,7 @@ $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)) { diff --git a/api/config.local.example.php b/api/config.local.example.php new file mode 100644 index 0000000..0b6b121 --- /dev/null +++ b/api/config.local.example.php @@ -0,0 +1,5 @@ + 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); diff --git a/api/delete_files.php b/api/delete_files.php index 5f99488..95f50c6 100644 --- a/api/delete_files.php +++ b/api/delete_files.php @@ -1,9 +1,10 @@ '내용은 비울 수 없습니다'], 400); $learnings[$key]['content'] = $content; } diff --git a/api/profile.php b/api/profile.php index beb0ee5..7698f32 100644 --- a/api/profile.php +++ b/api/profile.php @@ -1,7 +1,7 @@ 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)) { diff --git a/api/projects.php b/api/projects.php index 0e457e3..65ac00a 100644 --- a/api/projects.php +++ b/api/projects.php @@ -1,7 +1,7 @@ $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 필드 제거 diff --git a/api/upload.php b/api/upload.php index 2bd777b..6c2c4a8 100644 --- a/api/upload.php +++ b/api/upload.php @@ -1,9 +1,10 @@ '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); } diff --git a/index.html b/index.html index 13c57b6..87c3050 100644 --- a/index.html +++ b/index.html @@ -883,6 +883,7 @@ main { padding: 80px 8%; }