diff --git a/extend/r2-storage/src/R2FileHandler.php b/extend/r2-storage/src/R2FileHandler.php index d138b71..145e1cf 100644 --- a/extend/r2-storage/src/R2FileHandler.php +++ b/extend/r2-storage/src/R2FileHandler.php @@ -32,6 +32,51 @@ class R2FileHandler /** @var string|null */ private $lastError; + /** @var array 허용된 파일 타입 (화이트리스트) */ + private $allowedTypes = [ + // 이미지 + 'jpg' => ['image/jpeg'], + 'jpeg' => ['image/jpeg'], + 'png' => ['image/png'], + 'gif' => ['image/gif'], + 'webp' => ['image/webp'], + 'bmp' => ['image/bmp', 'image/x-ms-bmp'], + 'ico' => ['image/x-icon', 'image/vnd.microsoft.icon'], + // 비디오 + 'mp4' => ['video/mp4'], + 'webm' => ['video/webm'], + 'avi' => ['video/x-msvideo', 'video/avi'], + 'mov' => ['video/quicktime'], + 'mkv' => ['video/x-matroska'], + 'wmv' => ['video/x-ms-wmv'], + // 오디오 + 'mp3' => ['audio/mpeg', 'audio/mp3'], + 'wav' => ['audio/wav', 'audio/x-wav'], + 'ogg' => ['audio/ogg', 'application/ogg'], + 'flac' => ['audio/flac', 'audio/x-flac'], + 'aac' => ['audio/aac', 'audio/x-aac'], + 'm4a' => ['audio/mp4', 'audio/x-m4a'], + // 문서 + 'pdf' => ['application/pdf'], + 'doc' => ['application/msword'], + 'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + 'xls' => ['application/vnd.ms-excel'], + 'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + 'ppt' => ['application/vnd.ms-powerpoint'], + 'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation'], + 'hwp' => ['application/x-hwp', 'application/haansofthwp', 'application/octet-stream'], + 'hwpx' => ['application/hwp+zip', 'application/zip'], + 'txt' => ['text/plain'], + 'rtf' => ['application/rtf', 'text/rtf'], + 'csv' => ['text/csv', 'text/plain'], + // 압축 + 'zip' => ['application/zip', 'application/x-zip-compressed'], + 'rar' => ['application/x-rar-compressed', 'application/vnd.rar'], + '7z' => ['application/x-7z-compressed'], + 'tar' => ['application/x-tar'], + 'gz' => ['application/gzip', 'application/x-gzip'], + ]; + /** * R2FileHandler 생성자 * @@ -232,39 +277,16 @@ class R2FileHandler /** * 안전한 파일명 생성 (그누보드 스타일) * + * 화이트리스트 검증 후 호출되므로 확장자는 안전함 + * * @param string $originalName 원본 파일명 * @return string */ public function generateSafeFilename(string $originalName): string { - // 확장자 추출 + // 확장자 추출 (validateMimeType에서 이미 검증됨) $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); - // 위험한 확장자 처리 (이중 확장자 포함) - $dangerousExt = [ - 'php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'php8', 'phps', 'phar', 'pht', 'pgif', - 'inc', 'hphp', 'ctp', - 'jsp', 'jspx', 'jsw', 'jsv', 'jspf', 'wss', 'do', 'action', - 'cgi', 'pl', 'py', 'pyc', 'pyo', 'rb', 'sh', 'bash', - 'asp', 'aspx', 'cer', 'asa', 'asax', 'ashx', 'asmx', 'axd', - 'cfm', 'cfml', 'cfc', - 'htaccess', 'htpasswd', 'ini', 'config', - 'exe', 'dll', 'bat', 'cmd', 'com', 'msi', 'scr', 'vbs', 'vbe', 'js', 'jse', 'wsf', 'wsh', - 'svg', // SVG can contain JavaScript - ]; - - // 확장자 소문자 변환 - $ext = strtolower($ext); - - // 이중 확장자 체크 (test.php.jpg → php 감지) - $basename = pathinfo($originalName, PATHINFO_FILENAME); - $innerExt = strtolower(pathinfo($basename, PATHINFO_EXTENSION)); - if (in_array($innerExt, $dangerousExt)) { - $ext = $innerExt . '_' . $ext . '-x'; - } elseif (in_array($ext, $dangerousExt)) { - $ext .= '-x'; - } - // 해시 기반 파일명 생성 $hash = md5(sha1($_SERVER['REMOTE_ADDR'] ?? 'localhost') . microtime() . mt_rand()); $random = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz0123456789'), 0, 8); @@ -484,7 +506,7 @@ class R2FileHandler } /** - * MIME 타입 검증 (웹쉘 방지) + * 파일 타입 검증 (화이트리스트 기반) * * @param string $filePath 임시 파일 경로 * @param string $originalName 원본 파일명 @@ -496,59 +518,85 @@ class R2FileHandler return false; } - // finfo로 실제 MIME 타입 확인 - if (function_exists('finfo_open')) { - $finfo = finfo_open(FILEINFO_MIME_TYPE); - $realMime = finfo_file($finfo, $filePath); - finfo_close($finfo); + // 확장자 추출 + $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); - // PHP/스크립트 MIME 타입 차단 - $dangerousMimes = [ - 'application/x-php', - 'application/php', - 'application/x-httpd-php', - 'text/x-php', - 'text/php', - 'application/x-executable', - 'application/x-sharedlib', - 'application/x-shellscript', - 'text/x-shellscript', - 'text/x-python', - 'text/x-perl', - 'text/x-ruby', - ]; - - if (in_array($realMime, $dangerousMimes)) { + // 이중 확장자 체크 (test.php.jpg → 거부) + $basename = pathinfo($originalName, PATHINFO_FILENAME); + if (strpos($basename, '.') !== false) { + $innerExt = strtolower(pathinfo($basename, PATHINFO_EXTENSION)); + if (!isset($this->allowedTypes[$innerExt])) { + // 내부 확장자가 허용 목록에 없으면 의심스러운 파일 return false; } } - // 파일 시작 부분 검사 (PHP 태그, 스크립트 시그니처) - $handle = fopen($filePath, 'rb'); - if ($handle) { - $header = fread($handle, 1024); - fclose($handle); + // 허용된 확장자인지 확인 + if (!isset($this->allowedTypes[$ext])) { + return false; + } - // PHP 코드 시그니처 검사 - $patterns = [ - '/<\?php/i', - '/<\?=/i', - '/<\?[^x]/i', // ]*language\s*=\s*["\']?php/i', - '/<%\s/', // ASP 태그 - '/allowedTypes[$ext]; - foreach ($patterns as $pattern) { - if (preg_match($pattern, $header)) { - return false; - } + // finfo로 실제 MIME 타입 확인 + if (!function_exists('finfo_open')) { + return true; // finfo 없으면 확장자만으로 허용 + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $realMime = finfo_file($finfo, $filePath); + finfo_close($finfo); + + // 실제 MIME과 허용된 MIME 비교 + if (!in_array($realMime, $allowedMimes)) { + // application/octet-stream은 일부 파일에서 발생 (hwp 등) + if ($realMime === 'application/octet-stream' && in_array('application/octet-stream', $allowedMimes)) { + return true; } + return false; + } + + // 이미지는 추가 검증 (getimagesize) + if ($this->isImageExtension($ext)) { + return $this->validateImage($filePath); } return true; } + /** + * 이미지 확장자 여부 + * + * @param string $ext + * @return bool + */ + private function isImageExtension(string $ext): bool + { + return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico']); + } + + /** + * 이미지 파일 유효성 검증 + * + * @param string $filePath + * @return bool + */ + private function validateImage(string $filePath): bool + { + $imageInfo = @getimagesize($filePath); + if ($imageInfo === false) { + return false; + } + + // 유효한 이미지 타입인지 확인 + $validTypes = [ + IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF, + IMAGETYPE_WEBP, IMAGETYPE_BMP, IMAGETYPE_ICO, + ]; + + return in_array($imageInfo[2], $validTypes); + } + /** * 이미지 파일 여부 확인 * @@ -557,12 +605,8 @@ class R2FileHandler */ public function isImage(string $filename): bool { - $imageExt = defined('R2_ALLOWED_IMAGE_EXT') - ? explode(',', R2_ALLOWED_IMAGE_EXT) - : ['jpg', 'jpeg', 'gif', 'png', 'webp', 'svg']; - $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); - return in_array($ext, $imageExt); + return $this->isImageExtension($ext); } /** @@ -573,11 +617,28 @@ class R2FileHandler */ public function isAllowedExtension(string $filename): bool { - $allowedExt = defined('R2_ALLOWED_FILE_EXT') - ? explode(',', R2_ALLOWED_FILE_EXT) - : ['jpg', 'jpeg', 'gif', 'png', 'webp', 'svg', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'hwp', 'txt', 'zip', 'rar', '7z']; - $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); - return in_array($ext, $allowedExt); + return isset($this->allowedTypes[$ext]); + } + + /** + * 허용된 파일 타입 목록 조회 + * + * @return array + */ + public function getAllowedTypes(): array + { + return array_keys($this->allowedTypes); + } + + /** + * 허용된 파일 타입 추가 + * + * @param string $ext 확장자 + * @param array $mimes 허용 MIME 타입 배열 + */ + public function addAllowedType(string $ext, array $mimes): void + { + $this->allowedTypes[strtolower($ext)] = $mimes; } }