탈

최종 보안 소스입니다. 전문가님들 평가좀 해주세요.

· 2026-04-05 (일) 01:17:49 · 481 · 7
이전 보안 소스를 이젠 다 채웠습니다.
이 정도면 쓸만 할까요?
이게 쓸만하다면 CMS를 다시 싹 바꾸어야 합니다. ㅠㅠ

<?php
/**
 * DX_CMS Ultimate Security Core (5.3 LTS - COMPATIBILITY)
 * PHP 5.6, 7.x, 8.x 전버전 호환성 확보
 * 최신 문법(Type Hinting, Null Coalescing)을 하위 호환 코드로 교체
 * 보안 로직은 v5.2의 강력함을 그대로 유지
 */

if (!defined('DX_CMS')) {
    header('HTTP/1.1 403 Forbidden');
    exit('Direct access not allowed.');
}

class Secure
{
    private static $instance = null;

    private $isHttps = false;
    private $cspNonce = '';
    private $redis = null;
    private $redisFailUntil = 0;

    private $keyCsrf = 'dx_csrf';
    private $localRate = array();
    private $behavior = array();
    private $enableIpBinding = true;

    private $trustedProxyRanges = array(
        '127.0.0.1/32', '::1/128',
        '173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
        '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
        '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
        '2400:cb00::/32', '2606:4700::/32', '2a06:98c0::/29'
    );

    private $wafRules = array(
        'sql' => array(
            '/(union\s+all\s+select|select\s+.*\s+from\s+|information_schema)/i',
            '/(into\s+(outfile|dumpfile)|load_file|benchmark|sleep\()/i'
        ),
        'xss' => array(
            '/<script[^>]*>/i',
            '/(onmouseover|onerror|onload)\s*=/i',
            '/javascript:/i'
        ),
        'lfi' => array(
            '/\.\.\//',
            '/\/etc\/(passwd|shadow|group)/',
            '/(php|file|data|expect|zip):\/\//i'
        ),
        'cmd' => array(
            '/(;|\|\||&&)\s*(ls|cat|whoami|id|netstat|pwd|ifconfig)/i'
        )
    );

    private function __construct() {}

    public static function getInstance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    public function initSecurity() {
        $this->initRedis();
        $this->checkBan();
        $this->wafCheck();
        $this->detectBot();
        $this->behaviorCheck();
    }

    private function initRedis() {
        if (time() < $this->redisFailUntil || !class_exists('Redis')) return;
        try {
            $this->redis = new Redis();
            $this->redis->pconnect('127.0.0.1', 6379, 0.1);
        } catch (Exception $e) {
            $this->redisFailUntil = time() + 60;
        }
    }

    public function initSession($https = false) {
        $this->isHttps = $https;
        if (session_status() === PHP_SESSION_ACTIVE) return;

        ini_set('session.use_only_cookies', 1);
        ini_set('session.use_strict_mode', 1);

        // PHP 7.3 미만 호환을 위한 쿠키 설정 방식
        if (PHP_VERSION_ID >= 70300) {
            session_set_cookie_params(array(
                'lifetime' => 7200,
                'path'     => '/',
                'secure'   => $https,
                'httponly' => true,
                'samesite' => 'Lax'
            ));
        } else {
            // PHP 5.6/7.0 등 구버전은 samesite를 직접 문자열로 추가해야 할 수 있음
            session_set_cookie_params(7200, '/; SameSite=Lax', null, $https, true);
        }

        session_start();

        if (!isset($_SESSION['__regen'])) {
            $_SESSION['__regen'] = time();
        } elseif (time() - $_SESSION['__regen'] > 600) {
            session_regenerate_id(true);
            $_SESSION['__regen'] = time();
        }
    }

    public function sendSecurityHeaders() {
        if (headers_sent()) return;

        $nonce = $this->getCspNonce();
        header_remove('X-Powered-By');
        header('X-Frame-Options: SAMEORIGIN');
        header('X-Content-Type-Options: nosniff');
        header('Referrer-Policy: strict-origin-when-cross-origin');
        
        if ($this->isHttps) {
            header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
        }

        $csp = "default-src 'self'; img-src 'self' data: https:; ";
        $csp .= "script-src 'self' 'nonce-{$nonce}' 'unsafe-inline' https:; "; // 구버전 호환성 위해 unsafe-inline 보조
        $csp .= "style-src 'self' 'nonce-{$nonce}'; object-src 'none';";
        header("Content-Security-Policy: " . $csp);
    }

    public function getCspNonce() {
        if (!$this->cspNonce) {
            // PHP 5.6용 random_bytes 대체 (가용성 체크)
            if (function_exists('random_bytes')) {
                $this->cspNonce = base64_encode(random_bytes(16));
            } else {
                $this->cspNonce = base64_encode(openssl_random_pseudo_bytes(16));
            }
        }
        return $this->cspNonce;
    }

    private function wafCheck() {
        $input = json_encode($_REQUEST);
        foreach ($this->wafRules as $type => $rules) {
            foreach ($rules as $pattern) {
                if (preg_match($pattern, $input)) {
                    $this->block("WAF_DETECTED:" . $type);
                }
            }
        }
    }

    private function detectBot() {
        $ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
        if (!$ua || preg_match('/(curl|wget|python|scrapy|headless|selenium)/i', $ua)) {
            $this->block("BOT_BLOCKED");
        }
    }

    private function behaviorCheck() {
        $ip = $this->getClientIp();
        $now = time();
        if (!isset($this->behavior[$ip])) $this->behavior[$ip] = array();
        
        $this->behavior[$ip][] = $now;
        
        // PHP 5.6용 익명함수 문법 유지
        $this->behavior[$ip] = array_filter($this->behavior[$ip], function($t) use ($now) {
            return $t > $now - 10;
        });

        if (count($this->behavior[$ip]) > 40) {
            $this->block("FLOOD_ATTACK");
        }
    }

    public function getClientIp() {
        $remote = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '127.0.0.1';
        if ($this->isTrustedProxy($remote)) {
            if (isset($_SERVER['HTTP_CF_CONNECTING_IP'])) return $_SERVER['HTTP_CF_CONNECTING_IP'];
            if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
                $parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
                return trim($parts[0]);
            }
        }
        return $remote;
    }

    private function isTrustedProxy($ip) {
        foreach ($this->trustedProxyRanges as $range) {
            if ($this->ipInRange($ip, $range)) return true;
        }
        return false;
    }

    private function ipInRange($ip, $range) {
        if (strpos($range, '/') === false) return $ip === $range;
        list($subnet, $bits) = explode('/', $range);
        $ipBin = inet_pton($ip);
        $subBin = inet_pton($subnet);
        if (!$ipBin || !$subBin) return false;
        
        $mask = str_repeat("\xff", floor($bits / 8));
        if ($bits % 8) $mask .= chr(0xff << (8 - ($bits % 8)));
        $mask = str_pad($mask, strlen($ipBin), "\x00");
        return ($ipBin & $mask) === ($subBin & $mask);
    }

    public function csrfToken() {
        if (empty($_SESSION[$this->keyCsrf])) {
            $token = function_exists('random_bytes') ? bin2hex(random_bytes(32)) : bin2hex(openssl_random_pseudo_bytes(32));
            $_SESSION[$this->keyCsrf] = array(
                'token' => $token,
                'ip' => $this->getClientIp()
            );
        }
        return $_SESSION[$this->keyCsrf]['token'];
    }

    public function csrfCheck() {
        if ($_SERVER['REQUEST_METHOD'] === 'GET') return;
        $token = isset($_POST['_csrf']) ? $_POST['_csrf'] : (isset($_SERVER['HTTP_X_CSRF_TOKEN']) ? $_SERVER['HTTP_X_CSRF_TOKEN'] : '');
        $data = isset($_SESSION[$this->keyCsrf]) ? $_SESSION[$this->keyCsrf] : null;

        if (!$data || !hash_equals($data['token'], $token)) {
            $this->block('CSRF_INVALID');
        }
    }

    private function block($reason) {
        $ip = $this->getClientIp();
        if ($this->redis) {
            $this->redis->setex("dx:ban:$ip", 1800, $reason);
        }
        $this->log('BLOCK', $reason);
        http_response_code(403);
        exit("<h1>403 Forbidden</h1><p>Security Policy Violation: $reason</p>");
    }

    private function log($type, $msg) {
        $dir = __DIR__ . '/../logs';
        if (!is_dir($dir)) @mkdir($dir, 0711, true);
        $file = $dir . '/security_' . date('Ymd') . '.log';
        $entry = sprintf("[%s][%s][IP:%s] %s\n", date('H:i:s'), $type, $this->getClientIp(), $msg);
        @file_put_contents($file, $entry, FILE_APPEND | LOCK_EX);
    }

    public static function esc($str) {
        return htmlspecialchars((string)$str, ENT_QUOTES, 'UTF-8');
    }

    public static function safeUrl($url) {
        if (!$url) return '#';
        if (strpos($url, '/') === 0 || strpos($url, '#') === 0) return $url;
        if (preg_match('/^(javascript|data|vbscript):/i', $url)) return '#';
        return filter_var($url, FILTER_VALIDATE_URL) ? $url : '#';
    }
}
|

댓글 7개

저도 보안쪽은 자세히 몰라서 보안관련해서는 클로드코드에서 관련클래스를 만들어 사용하고있습니다.

알려준 내용을 클로드에 분석, 개선점을 살펴보라고 하니까 다음처럼 알려줍니다.

# DX_CMS Security Core 코드 분석 보고서
작성일: 2026-04-05
---
## 1. 전체 평가
보안의 핵심 요소(CSRF, CSP, WAF, 세션 보호, IP 처리)를 한 클래스에 잘 모아놓았고, 구조적으로 싱글톤 패턴을 적절히 사용했습니다. PHP 5.6~8.x 호환성 처리도 세심합니다.
다만 실제 서비스에 적용하기 전에 반드시 수정해야 할 문제가 있습니다. 아래에서 잘 된 부분과 문제점을 구분하여 정리합니다.

---
## 2. 잘 된 부분
(1) 싱글톤 패턴
보안 클래스는 요청당 하나만 존재해야 하므로 싱글톤이 적합합니다.
(2) CSP nonce
Content-Security-Policy에 nonce를 적용하여 XSS 공격을 효과적으로 방어합니다.
(3) CSRF 토큰
hash_equals()를 사용하여 타이밍 공격(timing attack)을 방어합니다. 토큰 생성에 random_bytes를 사용한 점도 좋습니다.
(4) WAF 규칙
SQL Injection, XSS, LFI(Local File Inclusion), Command Injection 4가지 주요 공격 패턴을 정규식으로 탐지합니다.
(5) 세션 보안
use_strict_mode, httponly, SameSite 설정, 10분마다 세션 ID 재생성 등 세션 하이재킹 방어가 잘 되어 있습니다.
(6) Cloudflare 프록시 IP 처리
Cloudflare의 IP 범위를 신뢰 목록에 등록하고, 신뢰 범위 내에서만 X-Forwarded-For, CF-Connecting-IP 헤더를 수용합니다. IP 스푸핑 방어에 효과적입니다.
(7) PHP 버전 호환
random_bytes 가용성 체크, session_set_cookie_params의 PHP 7.3 전후 분기 등 하위 호환 처리가 세심합니다.

---
## 3. 반드시 수정해야 할 문제 (심각도 높음)

### 문제 1: checkBan() 메서드 미정의 — 에러 발생
initSecurity()에서 $this->checkBan()을 호출하지만, 클래스 안에 checkBan() 메서드가 정의되어 있지 않습니다. 이 상태로 실행하면 PHP Fatal Error가 발생합니다.
추가해야 할 코드:
    private function checkBan() {
        if (!$this->redis) return;
        $ip = $this->getClientIp();
        $reason = $this->redis->get("dx:ban:$ip");
        if ($reason) {
            http_response_code(403);
            exit("<h1>403 Forbidden</h1>");
        }
    }

### 문제 2: behaviorCheck() — 작동하지 않음
현재 코드:
    private $behavior = array();
    private function behaviorCheck() {
        $ip = $this->getClientIp();
        $this->behavior[$ip][] = $now;
        if (count($this->behavior[$ip]) > 40) {
            $this->block("FLOOD_ATTACK");
        }
    }
$behavior는 인스턴스 변수입니다. PHP는 매 요청마다 새로운 프로세스를 생성하므로, $behavior는 항상 빈 배열로 시작합니다. 한 요청 안에서 40번 호출되는 경우는 없으므로 이 코드는 절대 발동하지 않습니다.
Redis에 카운터를 저장해야 요청 간에 공유됩니다:
    private function behaviorCheck() {
        if (!$this->redis) return;
        $ip = $this->getClientIp();
        $key = "dx:rate:$ip";
        $count = $this->redis->incr($key);
        if ($count === 1) $this->redis->expire($key, 10);
        if ($count > 40) $this->block("FLOOD_ATTACK");
    }
$localRate 변수도 동일한 문제로 사용되지 못합니다.

### 문제 3: detectBot() — 정상 사용자를 차단함
현재 코드:
    private function detectBot() {
        $ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';
        if (!$ua || preg_match('/(curl|wget|python|scrapy|headless|selenium)/i', $ua)) {
            $this->block("BOT_BLOCKED");
        }
    }
문제점:
- User-Agent가 비어있는 정상 브라우저도 존재합니다.
- 악의적 봇은 UA를 Chrome으로 위조하므로 이 방식으로 못 막습니다.
- 네이버 검색봇, 카카오 크롤러, 구글봇 등 정상 크롤러가 차단될 수 있습니다.
- "python"이 포함된 정상 서비스 UA도 차단됩니다.
결과적으로 악의적 봇은 우회하고, 정상 사용자와 검색엔진만 차단하는 역효과가 발생합니다.
개선 방향:
- UA 기반 차단을 제거하거나, 화이트리스트 방식으로 변경합니다.
- 봇 차단은 rate limiting(문제 2의 수정)으로 처리하는 것이 효과적입니다.
- 꼭 UA를 검사하려면 block() 대신 로그만 남기고, reCAPTCHA를 보여주는 방식이 안전합니다.

### 문제 4: WAF 오탐 — 게시글이 차단됨
현재 코드:
    private function wafCheck() {
        $input = json_encode($_REQUEST);
        foreach ($this->wafRules as $type => $rules) { ... }
    }
$_REQUEST 전체를 검사하므로 게시판에 다음과 같은 글을 쓰면 차단됩니다:
- "SELECT 항목 FROM 메뉴에서 골라주세요" → SQL Injection으로 오탐
- "javascript: 자바스크립트란?" → XSS로 오탐
- 코드 예제를 포함한 기술 게시글 → 거의 확실하게 차단
개선 방향:
WAF는 $_GET과 $_COOKIE에만 적용하고, $_POST의 본문 필드(content, body 등)는 제외해야 합니다:
    private function wafCheck() {
        // GET 파라미터와 쿠키만 검사
        $checkTargets = array_merge($_GET, $_COOKIE);
       
        // POST에서는 본문(content, body) 필드 제외
        $postCheck = $_POST;
        unset($postCheck['content'], $postCheck['body'], $postCheck['description']);
        $checkTargets = array_merge($checkTargets, $postCheck);
       
        $input = json_encode($checkTargets);
        // ... 기존 검사 로직
    }

---
## 4. 보안 허점 (심각도 중간)

### 문제 5: block()에서 차단 사유를 공격자에게 노출
현재 코드:
    exit("<h1>403 Forbidden</h1><p>Security Policy Violation: $reason</p>");
"WAF_DETECTED:sql", "FLOOD_ATTACK" 같은 내부 사유를 공격자에게 보여주면 어떤 규칙에 걸렸는지 알 수 있어 우회 힌트를 제공합니다.
개선:
    $this->log('BLOCK', $reason);  // 로그에만 기록
    http_response_code(403);
    exit("<h1>403 Forbidden</h1>");  // 사유는 노출하지 않음

### 문제 6: CSRF IP 바인딩 — 저장만 하고 검증 안 함
현재 코드:
    // 토큰 생성 시 IP를 저장
    $_SESSION[$this->keyCsrf] = array(
        'token' => $token,
        'ip' => $this->getClientIp()
    );
    // 검증 시 IP를 비교하지 않음
    public function csrfCheck() {
        if (!$data || !hash_equals($data['token'], $token)) {
            $this->block('CSRF_INVALID');
        }
        // IP 비교 없음!
    }
$enableIpBinding = true로 설정되어 있지만 실제로 사용하는 곳이 없습니다.
개선:
    public function csrfCheck() {
        // 토큰 검증
        if (!$data || !hash_equals($data['token'], $token)) {
            $this->block('CSRF_INVALID');
        }
        // IP 바인딩 검증
        if ($this->enableIpBinding && $data['ip'] !== $this->getClientIp()) {
            $this->block('CSRF_IP_MISMATCH');
        }
    }
단, 모바일 네트워크에서 IP가 자주 바뀌는 사용자를 고려하여 enableIpBinding을 설정에서 끌 수 있도록 해야 합니다.

---
## 5. 개선 권장 사항 (심각도 낮음)

### 문제 7: 보안 헤더 — style-src에 unsafe-inline 누락
현재:
    style-src 'self' 'nonce-{$nonce}';
대부분의 실제 사이트에서는 인라인 style 태그나 style 속성을 사용합니다. 이 설정이면 페이지 스타일이 깨질 수 있습니다. 현실적으로 style-src에는 'unsafe-inline'을 추가해야 합니다.

### 문제 8: Redis 실패 시 보안 무력화
Redis가 다운되면 checkBan(), behaviorCheck() 모두 건너뜁니다. Redis 없이도 최소한의 방어가 작동해야 합니다.
방법 1: 파일 기반 fallback (성능은 낮지만 안전)
방법 2: APCu 캐시 사용 (Redis보다 가볍고 단일 서버에 적합)

### 문제 9: $localRate 미사용 변수
private $localRate = array(); 가 선언되어 있지만 어디서도 사용되지 않습니다. 제거하거나, behaviorCheck()와 통합해야 합니다.

---
## 6. 수정 우선순위 요약
1순위 (서비스 불가): checkBan() 메서드 누락 → Fatal Error 발생
2순위 (보안 무력화): behaviorCheck() 메모리 한정 → Rate limiting 작동 안 함  
3순위 (서비스 장애): detectBot() 정상 사용자 차단 → 검색엔진, 일반 사용자 차단
4순위 (서비스 장애): WAF 오탐 → 게시글 작성 시 차단
5순위 (정보 유출): block()에서 차단 사유 노출 → 공격자에게 우회 힌트 제공
6순위 (보안 허점): CSRF IP 바인딩 미검증 → 저장만 하고 비교 안 함
7순위 (코드 정리): localRate 미사용 변수, style-src 설정, Redis fallback

---
## 7. 결론
보안 설계의 방향은 올바르지만, 1~4번 문제를 수정하지 않으면 실서비스에 적용할 수 없습니다. 특히 behaviorCheck()가 작동하지 않아 rate limiting이 무력화된 상태이고, detectBot()과 WAF 오탐으로 정상 사용자가 차단될 수 있습니다.
1~6번을 수정한 후 테스트 환경에서 충분히 검증한 뒤 서비스에 적용하는 것을 권장합니다.
 

클로드코드에게 발견한 오류를 수정하고 다시 만들어달라고 하니까 다음처럼 만들어주네요^^

<?php
/**
 * DX_CMS Ultimate Security Core (5.4 - IMPROVED)
 * PHP 5.6, 7.x, 8.x 전버전 호환성 확보
 * 
 * v5.3 대비 개선사항:
 * 1. checkBan() 메서드 추가 (누락 수정)
 * 2. behaviorCheck() Redis 기반으로 변경 (메모리 한정 → 요청 간 공유)
 * 3. detectBot() 정상 사용자 차단 방지 (로그만 남기고 차단 안 함)
 * 4. WAF 오탐 방지 (POST 본문 필드 제외)
 * 5. block()에서 차단 사유 비노출
 * 6. CSRF IP 바인딩 검증 추가
 * 7. style-src unsafe-inline 추가
 * 8. Redis 실패 시 파일 기반 fallback
 * 9. 미사용 변수 제거
 */

if (!defined('DX_CMS')) {
    header('HTTP/1.1 403 Forbidden');
    exit('Direct access not allowed.');
}

class Secure
{
    private static $instance = null;

    private $isHttps = false;
    private $cspNonce = '';
    private $redis = null;
    private $redisFailUntil = 0;

    private $keyCsrf = 'dx_csrf';
    private $enableIpBinding = true;

    // Rate limiting 설정
    private $rateWindow = 10;       // 초 단위 (10초 내)
    private $rateLimit = 40;        // 최대 요청 수

    // WAF 검사에서 제외할 POST 필드명 (게시판 본문 등)
    private $wafExcludeFields = array(
        'content', 'body', 'description', 'editor_content',
        'comment', 'message', 'text', 'detail'
    );

    private $trustedProxyRanges = array(
        '127.0.0.1/32', '::1/128',
        '173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
        '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
        '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
        '2400:cb00::/32', '2606:4700::/32', '2a06:98c0::/29'
    );

    // 검색엔진 봇 화이트리스트 (차단하면 안 되는 봇)
    private $allowedBots = array(
        'Googlebot', 'Yeti', 'bingbot', 'DuckDuckBot',
        'Baiduspider', 'kakaotalk-scrap', 'facebookexternalhit',
        'Twitterbot', 'LinkedInBot'
    );

    private $wafRules = array(
        'sql' => array(
            '/(union\s+all\s+select|select\s+.*\s+from\s+|information_schema)/i',
            '/(into\s+(outfile|dumpfile)|load_file|benchmark|sleep\()/i'
        ),
        'xss' => array(
            '/<script[^>]*>/i',
            '/(onmouseover|onerror|onload)\s*=/i',
            '/javascript:/i'
        ),
        'lfi' => array(
            '/\.\.\//',
            '/\/etc\/(passwd|shadow|group)/',
            '/(php|file|data|expect|zip):\/\//i'
        ),
        'cmd' => array(
            '/(;|\|\||&&)\s*(ls|cat|whoami|id|netstat|pwd|ifconfig)/i'
        )
    );

    private function __construct() {}

    public static function getInstance()
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    // ============================================
    // 초기화
    // ============================================

    /**
     * 보안 초기화 — 모든 페이지 상단에서 호출
     */
    public function initSecurity()
    {
        $this->initRedis();
        $this->checkBan();
        $this->wafCheck();
        $this->detectSuspiciousBot();
        $this->behaviorCheck();
    }

    /**
     * Redis 연결 (실패 시 60초간 재시도 안 함)
     */
    private function initRedis()
    {
        if (time() < $this->redisFailUntil || !class_exists('Redis')) return;
        try {
            $this->redis = new Redis();
            $this->redis->pconnect('127.0.0.1', 6379, 0.1);
        } catch (Exception $e) {
            $this->redis = null;
            $this->redisFailUntil = time() + 60;
        }
    }

    // ============================================
    // 세션
    // ============================================

    /**
     * 보안 세션 초기화
     */
    public function initSession($https = false)
    {
        $this->isHttps = $https;
        if (session_status() === PHP_SESSION_ACTIVE) return;

        ini_set('session.use_only_cookies', 1);
        ini_set('session.use_strict_mode', 1);

        if (PHP_VERSION_ID >= 70300) {
            session_set_cookie_params(array(
                'lifetime' => 7200,
                'path'     => '/',
                'secure'   => $https,
                'httponly'  => true,
                'samesite'  => 'Lax'
            ));
        } else {
            session_set_cookie_params(7200, '/; SameSite=Lax', null, $https, true);
        }

        session_start();

        // 10분마다 세션 ID 재생성 (세션 하이재킹 방어)
        if (!isset($_SESSION['__regen'])) {
            $_SESSION['__regen'] = time();
        } elseif (time() - $_SESSION['__regen'] > 600) {
            session_regenerate_id(true);
            $_SESSION['__regen'] = time();
        }
    }

    // ============================================
    // 보안 헤더
    // ============================================

    /**
     * HTTP 보안 헤더 전송
     */
    public function sendSecurityHeaders()
    {
        if (headers_sent()) return;

        $nonce = $this->getCspNonce();
        header_remove('X-Powered-By');
        header('X-Frame-Options: SAMEORIGIN');
        header('X-Content-Type-Options: nosniff');
        header('Referrer-Policy: strict-origin-when-cross-origin');

        if ($this->isHttps) {
            header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
        }

        // [개선] style-src에 unsafe-inline 추가 (인라인 스타일 허용)
        $csp  = "default-src 'self'; ";
        $csp .= "img-src 'self' data: https:; ";
        $csp .= "script-src 'self' 'nonce-{$nonce}' 'unsafe-inline' https:; ";
        $csp .= "style-src 'self' 'unsafe-inline' 'nonce-{$nonce}'; ";
        $csp .= "object-src 'none';";
        header("Content-Security-Policy: " . $csp);
    }

    /**
     * CSP nonce 생성
     */
    public function getCspNonce()
    {
        if (!$this->cspNonce) {
            if (function_exists('random_bytes')) {
                $this->cspNonce = base64_encode(random_bytes(16));
            } else {
                $this->cspNonce = base64_encode(openssl_random_pseudo_bytes(16));
            }
        }
        return $this->cspNonce;
    }

    // ============================================
    // WAF (Web Application Firewall)
    // ============================================

    /**
     * [개선] WAF 검사 — POST 본문 필드 제외하여 오탐 방지
     * 
     * 이전: $_REQUEST 전체를 검사 → 게시글 본문에 "SELECT FROM" 등 쓰면 차단
     * 변경: $_GET, $_COOKIE는 전체 검사, $_POST는 본문 필드 제외 후 검사
     */
    private function wafCheck()
    {
        // GET 파라미터 + 쿠키 (전체 검사)
        $checkData = array_merge($_GET, $_COOKIE);

        // POST에서 본문 필드 제외 후 추가
        $postCheck = $_POST;
        foreach ($this->wafExcludeFields as $field) {
            unset($postCheck[$field]);
        }
        $checkData = array_merge($checkData, $postCheck);

        $input = json_encode($checkData);

        foreach ($this->wafRules as $type => $rules) {
            foreach ($rules as $pattern) {
                if (preg_match($pattern, $input)) {
                    $this->block("WAF_DETECTED:" . $type);
                }
            }
        }
    }

    // ============================================
    // 봇 탐지
    // ============================================

    /**
     * [개선] 의심 봇 탐지 — 차단 대신 로그만 기록
     * 
     * 이전: curl, python 등 UA 포함 시 즉시 차단 → 정상 사용자/검색봇 차단
     * 변경: 의심 UA는 로그만 남기고 통과. 실제 차단은 rate limiting으로 처리
     * 검색엔진 봇은 화이트리스트로 보호
     */
    private function detectSuspiciousBot()
    {
        $ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '';

        // UA 없으면 로그만 (차단 안 함 — 일부 정상 브라우저도 UA 없음)
        if (!$ua) {
            $this->log('WARN', 'Empty User-Agent detected');
            return;
        }

        // 검색엔진 봇은 무조건 통과
        foreach ($this->allowedBots as $bot) {
            if (stripos($ua, $bot) !== false) {
                return;
            }
        }

        // 자동화 도구 UA — 로그 기록 (차단은 rate limiting에 위임)
        if (preg_match('/(curl|wget|python|scrapy|headless|selenium|phantomjs)/i', $ua)) {
            $this->log('WARN', 'Suspicious UA: ' . substr($ua, 0, 100));
        }
    }

    // ============================================
    // Rate Limiting (요청 속도 제한)
    // ============================================

    /**
     * [개선] 요청 속도 제한 — Redis 기반 (파일 fallback 포함)
     * 
     * 이전: 인스턴스 변수($behavior)에 저장 → PHP 특성상 매 요청 초기화되어 작동 안 함
     * 변경: Redis에 IP별 카운터 저장 → 요청 간 공유되어 정상 작동
     * Redis 다운 시: 파일 기반 fallback으로 최소한의 보호 유지
     */
    private function behaviorCheck()
    {
        $ip = $this->getClientIp();

        if ($this->redis) {
            // Redis 기반 rate limiting
            $key = "dx:rate:" . $ip;
            $count = $this->redis->incr($key);
            if ($count === 1) {
                $this->redis->expire($key, $this->rateWindow);
            }
            if ($count > $this->rateLimit) {
                $this->block("FLOOD_ATTACK");
            }
        } else {
            // Redis 없을 때 파일 기반 fallback
            $this->fileBasedRateLimit($ip);
        }
    }

    /**
     * 파일 기반 rate limiting (Redis fallback)
     * 성능은 낮지만 Redis 다운 시에도 최소한의 보호 제공
     */
    private function fileBasedRateLimit($ip)
    {
        $dir = defined('DX_LOG_DIR') ? DX_LOG_DIR : __DIR__ . '/../logs';
        if (!is_dir($dir)) @mkdir($dir, 0711, true);

        $file = $dir . '/rate_' . md5($ip) . '.tmp';
        $now = time();

        $data = array('count' => 0, 'start' => $now);

        if (is_file($file)) {
            $content = @file_get_contents($file);
            if ($content) {
                $saved = @unserialize($content);
                if (is_array($saved) && isset($saved['start'])) {
                    if ($now - $saved['start'] < $this->rateWindow) {
                        $data = $saved;
                    }
                    // 윈도우 초과 시 리셋
                }
            }
        }

        $data['count']++;
        @file_put_contents($file, serialize($data), LOCK_EX);

        if ($data['count'] > $this->rateLimit) {
            $this->block("FLOOD_ATTACK");
        }

        // 오래된 rate limit 파일 정리 (1% 확률로 실행)
        if (mt_rand(1, 100) === 1) {
            $this->cleanRateFiles($dir, $now);
        }
    }

    /**
     * 오래된 rate limit 임시 파일 정리
     */
    private function cleanRateFiles($dir, $now)
    {
        $files = glob($dir . '/rate_*.tmp');
        if (!$files) return;
        foreach ($files as $f) {
            if ($now - filemtime($f) > 60) {
                @unlink($f);
            }
        }
    }

    // ============================================
    // 차단/해제
    // ============================================

    /**
     * [추가] IP 차단 여부 확인
     * 
     * 이전: initSecurity()에서 호출하지만 메서드가 정의되지 않아 Fatal Error 발생
     * 변경: Redis에서 차단 정보 확인, 차단된 IP면 즉시 403 응답
     */
    private function checkBan()
    {
        $ip = $this->getClientIp();

        if ($this->redis) {
            $reason = $this->redis->get("dx:ban:" . $ip);
            if ($reason) {
                $this->log('BAN', 'Banned IP attempted access: ' . $ip);
                http_response_code(403);
                exit("<h1>403 Forbidden</h1>");
            }
        } else {
            // Redis 없을 때 파일 기반 확인
            $dir = defined('DX_LOG_DIR') ? DX_LOG_DIR : __DIR__ . '/../logs';
            $banFile = $dir . '/ban_' . md5($ip) . '.tmp';
            if (is_file($banFile)) {
                $expiry = @file_get_contents($banFile);
                if ($expiry && time() < (int)$expiry) {
                    http_response_code(403);
                    exit("<h1>403 Forbidden</h1>");
                } else {
                    @unlink($banFile);
                }
            }
        }
    }

    /**
     * [개선] 차단 처리 — 사유를 사용자에게 노출하지 않음
     * 
     * 이전: "Security Policy Violation: WAF_DETECTED:sql" 노출 → 공격자에게 우회 힌트
     * 변경: 로그에만 기록, 사용자에게는 "403 Forbidden"만 표시
     */
    private function block($reason)
    {
        $ip = $this->getClientIp();

        if ($this->redis) {
            $this->redis->setex("dx:ban:" . $ip, 1800, $reason);
        } else {
            // Redis 없을 때 파일 기반 차단
            $dir = defined('DX_LOG_DIR') ? DX_LOG_DIR : __DIR__ . '/../logs';
            if (!is_dir($dir)) @mkdir($dir, 0711, true);
            $banFile = $dir . '/ban_' . md5($ip) . '.tmp';
            @file_put_contents($banFile, (string)(time() + 1800), LOCK_EX);
        }

        $this->log('BLOCK', $reason);
        http_response_code(403);
        exit("<h1>403 Forbidden</h1>");
    }

    // ============================================
    // CSRF 보호
    // ============================================

    /**
     * CSRF 토큰 생성 및 반환
     */
    public function csrfToken()
    {
        if (empty($_SESSION[$this->keyCsrf])) {
            if (function_exists('random_bytes')) {
                $token = bin2hex(random_bytes(32));
            } else {
                $token = bin2hex(openssl_random_pseudo_bytes(32));
            }
            $_SESSION[$this->keyCsrf] = array(
                'token' => $token,
                'ip'    => $this->getClientIp(),
                'time'  => time()
            );
        }
        return $_SESSION[$this->keyCsrf]['token'];
    }

    /**
     * [개선] CSRF 토큰 검증 — IP 바인딩 검증 추가
     * 
     * 이전: IP를 저장하지만 검증에서 비교하지 않음
     * 변경: $enableIpBinding이 true이면 토큰 생성 시 IP와 현재 IP를 비교
     * 모바일 네트워크에서 IP가 자주 바뀌는 경우 enableIpBinding을 false로 설정
     */
    public function csrfCheck()
    {
        if ($_SERVER['REQUEST_METHOD'] === 'GET') return;

        $token = isset($_POST['_csrf'])
            ? $_POST['_csrf']
            : (isset($_SERVER['HTTP_X_CSRF_TOKEN']) ? $_SERVER['HTTP_X_CSRF_TOKEN'] : '');

        $data = isset($_SESSION[$this->keyCsrf]) ? $_SESSION[$this->keyCsrf] : null;

        // 토큰 검증
        if (!$data || !isset($data['token']) || !hash_equals($data['token'], $token)) {
            $this->block('CSRF_INVALID');
        }

        // IP 바인딩 검증
        if ($this->enableIpBinding && isset($data['ip'])) {
            if ($data['ip'] !== $this->getClientIp()) {
                $this->log('WARN', 'CSRF IP mismatch: session=' . $data['ip'] . ' current=' . $this->getClientIp());
                $this->block('CSRF_IP_MISMATCH');
            }
        }

        // 토큰 사용 후 재생성 (일회용)
        unset($_SESSION[$this->keyCsrf]);
    }

    /**
     * IP 바인딩 설정 변경
     * 모바일 네트워크 등 IP가 자주 바뀌는 환경에서는 false로 설정
     */
    public function setIpBinding($enable)
    {
        $this->enableIpBinding = (bool)$enable;
    }

    // ============================================
    // IP 관련
    // ============================================

    /**
     * 클라이언트 실제 IP 반환
     * Cloudflare 등 신뢰 프록시 뒤에서도 정확한 IP 획득
     */
    public function getClientIp()
    {
        $remote = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '127.0.0.1';
        if ($this->isTrustedProxy($remote)) {
            if (isset($_SERVER['HTTP_CF_CONNECTING_IP'])) {
                return $_SERVER['HTTP_CF_CONNECTING_IP'];
            }
            if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
                $parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
                return trim($parts[0]);
            }
        }
        return $remote;
    }

    /**
     * 신뢰 프록시 여부 확인
     */
    private function isTrustedProxy($ip)
    {
        foreach ($this->trustedProxyRanges as $range) {
            if ($this->ipInRange($ip, $range)) return true;
        }
        return false;
    }

    /**
     * IP가 CIDR 범위에 포함되는지 확인
     */
    private function ipInRange($ip, $range)
    {
        if (strpos($range, '/') === false) return $ip === $range;
        list($subnet, $bits) = explode('/', $range);
        $ipBin = inet_pton($ip);
        $subBin = inet_pton($subnet);
        if (!$ipBin || !$subBin) return false;

        $mask = str_repeat("\xff", floor($bits / 8));
        if ($bits % 8) $mask .= chr(0xff << (8 - ($bits % 8)));
        $mask = str_pad($mask, strlen($ipBin), "\x00");
        return ($ipBin & $mask) === ($subBin & $mask);
    }

    // ============================================
    // 로그
    // ============================================

    /**
     * 보안 로그 기록
     */
    private function log($type, $msg)
    {
        $dir = defined('DX_LOG_DIR') ? DX_LOG_DIR : __DIR__ . '/../logs';
        if (!is_dir($dir)) @mkdir($dir, 0711, true);
        $file = $dir . '/security_' . date('Ymd') . '.log';
        $entry = sprintf(
            "[%s][%s][IP:%s][UA:%s] %s\n",
            date('H:i:s'),
            $type,
            $this->getClientIp(),
            substr(isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '-', 0, 80),
            $msg
        );
        @file_put_contents($file, $entry, FILE_APPEND | LOCK_EX);
    }

    // ============================================
    // 유틸리티 (정적 메서드)
    // ============================================

    /**
     * HTML 이스케이프
     */
    public static function esc($str)
    {
        return htmlspecialchars((string)$str, ENT_QUOTES, 'UTF-8');
    }

    /**
     * URL 안전성 검증
     */
    public static function safeUrl($url)
    {
        if (!$url) return '#';
        if (strpos($url, '/') === 0 || strpos($url, '#') === 0) return $url;
        if (preg_match('/^(javascript|data|vbscript):/i', $url)) return '#';
        return filter_var($url, FILTER_VALIDATE_URL) ? $url : '#';
    }
}

2026-04-05 (일) 02:47:14
^^ 감사합니다.
많은 도움이 될것 같습니다. 
다시 한번 감사드립니다.

이 소스로 적용해보도록 하겠습니다. 감사합니다.
2026-04-05 (일) 03:55:38

소중하게 공유해주신 소스를 적용해 보았습니다. 진심으로 감사드립니다. 앞으로도 잘 부탁드립니다.

아직 많이 부족한 상태이지만, CMS를 직접 구현해보고자 노력하며 하나씩 배워가고 있습니다. 쉽지 않은 과정이지만, 이렇게 도움을 받으며 한 걸음씩 나아가고 있습니다.
 

귀중한 지식과 경험을 나눠주셔서 큰 도움이 되었습니다. 다시 한 번 감사드리며, 앞으로도 많은 조언 부탁드립니다.

감사합니다.

"오~ 100% 완벽해" 라고 해도
시간이 지나면 어딘가는 뚫려 있는게 보안이더라구요 ^^;;;
그러니 항상 주시하는게 제일 좋은거 같습니다
더군다나 필요에 의해 계속 늘어나게 되더라구요 처음엔 한페이지 그 다음엔 두페이지 그다음엔 하나의 폴더를 가득~


제 경우엔 화이트 리스트 정책에 의해 관리자와 같이 허용되는 아이피와 레벨에 한해서
소스파일과 같은 특정 파일에 수정이나 관리를 위해 접근할 수 있고
그 외의 사용자는 아예 404 오류 페이지로 넘어가게 했습니다 그런 부분도 한번 생각해 보세요 그런대로 보안에 작은 도움은 됩니다
 
또한 지금은 하나의 서버에 하나의 소스로 동작하는 것을 생각하고 계시겠지만
떄에 따라서 또는 규모에 따라서 여러개의 파일서버, 디비 서버, 이미지 서버를 통해  디비, 일반 파일,  이미지 파일등을 관리할 수도 있으니 그 확장성 까지 생각해 두셔야 나중에 손이 덜 갈거 같습니다
2026-04-05 (일) 02:48:54
끝이 없네요. 이것 보면 저것이 저것 보면 이것이 ㅎㅎㅎ
술래잡기 ^^;;
지금 적용하다가 지금 다 엉망이 되가고 있습니다.
전부 바꿔야 하는 상황이 왔습니다.

윈도우 프로그램도 만들어서 배포하고 있는데 ㅠㅠ 다 엉켰습니다. 엉엉

에뜨락님 감사합니다.
2026-04-05 (일) 04:02:55

여러 가지로 얽혀 있던 부분들을 정리한 후, 다시 차분한 마음으로 답변을 드립니다.
 

말씀해주신 것처럼, 완벽한 구조란 존재하기 어렵다는 점에 깊이 공감하고 있습니다. 화이트리스트 정책에 대해서도 조언해주신 내용을 바탕으로, 보다 신중하게 검토해보겠습니다. 현재는 김철용님께서 공유해주신 소스를 기반으로 설정을 진행해둔 상태입니다.
 

또한, 하나의 파일로 구성된 부분은 기본 버전을 의미한 것이며, 궁극적으로는 Override + Hook 구조를 기반으로 다양한 개발자분들께서 자유롭게 확장하고 서로 공유할 수 있는 방향을 지향하고 있습니다. 저 역시 지속적으로 보완과 패치를 이어가겠지만, 더 많은 뛰어난 개발자분들이 함께 참여해 주시기를 바라는 마음입니다.
 

항상 정성스럽게 남겨주시는 말씀들을 깊이 새기고 있습니다. 진심으로 감사드립니다.

댓글을 작성하시려면 로그인이 필요합니다.

자유게시판

203,021건
+
제목 글쓴이 날짜 조회
04-06 조회 521
04-06 조회 539
04-06 조회 430
04-06 조회 472
04-06 조회 752
04-06 조회 424
04-05 조회 473
04-05 조회 543
04-05 조회 689
04-05 조회 434
04-05 조회 459
04-05 조회 329
04-05 조회 359
04-05 조회 482
04-04 조회 554
04-04 조회 399
04-04 조회 504
04-04 조회 395
04-04 조회 768
04-04 조회 451