회원가입 | 고객센터 |
DESIGNONEX DXCMS BOARD
로그인
DESIGNONEX
DXCMS BOARD
About MB └ 메뉴얼 └ 이용약관 └ MB키 발급 └ 업데이트 DXCMS └ 메뉴얼 └ 다운로드 └ Themes └ Plugin └ Skin └ 사용후기 └ 마켓개발자 키 발급 DXB └ DXB Documentation └ InterfaceGallery └ Download └ 사용후기 └ 디자인소스 Service 공지사항 자유게시판 Q&A PR리그 포인트게임
로그인 회원가입

#2026.04.04 DXCMS BOARD 작업 현황

A Administrator
2026.04.05 04:17(수정됨) 8 0
<?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',
            '/blocked:/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 : '#';
    }
}


그누보드 회원 김철용님께서 정리해주신 소스를 기반으로, 보안 코드를 우선적으로 정리하였습니다. 귀중한 지식을 공유해주신 점에 대해 진심으로 감사드립니다.
AI의 도움도 물론 중요하지만, 결국 그 방향을 설정하고 완성해 나가는 것은 개발자의 몫이라고 생각합니다.
현재 적용한 보안 소스로 인해 일부 기능에서 정상 동작하지 않는 부분이 확인되고 있습니다. 다소 시간이 걸리더라도 하나하나 점검하며 퍼즐을 맞추듯 해결해 나가겠습니다.

이번 패치가 마무리되면, 전체적인 배포 일정도 어느 정도 가시화될 것으로 보입니다. 다만, 해당 패치에 소요되는 시간이 변수로 작용할 수 있어 그 부분을 신중하게 지켜보고 있습니다.

앞으로도 많은 관심과 조언 부탁드립니다.
감사합니다.

댓글0

로그인 후 댓글을 작성할 수 있습니다.
자유게시판 21
#2026.04.04 DXCMS BOARD 작업 현황
A Administrator 04.05 조회 8
윈도우 프로그램 개발 시작
A Administrator 04.04 조회 7
18
의견을 드려 봅니다.  [1]
여유당
04.03 12
의견을 드려 봅니다. [1]
여유당 04.03 조회 12
17
MB브릿지 명칭관련....  [4]
여유당
04.02 17
MB브릿지 명칭관련.... [4]
여유당 04.02 조회 17
16
#2026.04.01 DXCMS BOARD 작업 현황
Administrator
04.02 17
#2026.04.01 DXCMS BOARD 작업 현황
A Administrator 04.02 조회 17
15
안녕하세요  [3]
여유당
04.01 18
안녕하세요 [3]
여유당 04.01 조회 18
#2026.03.31 / DXCMS BOARD 작업 현황
A Administrator 04.01 조회 13
#2026.03.30 / DXCMS BOARD 작업 현황
A Administrator 03.31 조회 14
12
CMS 개발 동기 (이야기 1)
Administrator
03.31 14
CMS 개발 동기 (이야기 1)
A Administrator 03.31 조회 14
#2026.03.25 / DXCMS BOARD 작업 현황
A Administrator 03.25 조회 24
#2026.03.24 / DXCMS BOARD 작업 현황
A Administrator 03.25 조회 19
#2026.03.23 / DXCMS BOARD 작업 현황 [1]
A Administrator 03.24 조회 20