refactor: 화이트리스트 기반 파일 검증으로 전환
- 블랙리스트(위험 확장자 차단) → 화이트리스트(허용 타입만 통과) - 미디어/문서/압축 파일만 허용 (40+ 확장자) - MIME 타입과 확장자 매핑으로 정확한 검증 - 이미지는 getimagesize()로 추가 검증 - 이중 확장자 탐지 유지 (test.php.jpg 차단) - 동적 허용 타입 추가 기능 (addAllowedType) 보안 향상: 알려지지 않은 공격 벡터도 자동 차단 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
// 확장자 추출
|
||||
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
|
||||
// 이중 확장자 체크 (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;
|
||||
}
|
||||
}
|
||||
|
||||
// 허용된 확장자인지 확인
|
||||
if (!isset($this->allowedTypes[$ext])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedMimes = $this->allowedTypes[$ext];
|
||||
|
||||
// finfo로 실제 MIME 타입 확인
|
||||
if (function_exists('finfo_open')) {
|
||||
if (!function_exists('finfo_open')) {
|
||||
return true; // finfo 없으면 확장자만으로 허용
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$realMime = finfo_file($finfo, $filePath);
|
||||
finfo_close($finfo);
|
||||
|
||||
// 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)) {
|
||||
// 실제 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 시작 부분 검사 (PHP 태그, 스크립트 시그니처)
|
||||
$handle = fopen($filePath, 'rb');
|
||||
if ($handle) {
|
||||
$header = fread($handle, 1024);
|
||||
fclose($handle);
|
||||
|
||||
// PHP 코드 시그니처 검사
|
||||
$patterns = [
|
||||
'/<\?php/i',
|
||||
'/<\?=/i',
|
||||
'/<\?[^x]/i', // <?xml 제외
|
||||
'/<script[^>]*language\s*=\s*["\']?php/i',
|
||||
'/<%\s/', // ASP 태그
|
||||
'/<jsp:/i',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $header)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user