refactor: 화이트리스트 기반 파일 검증으로 전환

- 블랙리스트(위험 확장자 차단) → 화이트리스트(허용 타입만 통과)
- 미디어/문서/압축 파일만 허용 (40+ 확장자)
- MIME 타입과 확장자 매핑으로 정확한 검증
- 이미지는 getimagesize()로 추가 검증
- 이중 확장자 탐지 유지 (test.php.jpg 차단)
- 동적 허용 타입 추가 기능 (addAllowedType)

보안 향상: 알려지지 않은 공격 벡터도 자동 차단

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-10 15:14:53 +09:00
parent 1d84bb603e
commit 1b319b60c3

View File

@@ -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', // <?xml 제외
'/<script[^>]*language\s*=\s*["\']?php/i',
'/<%\s/', // ASP 태그
'/<jsp:/i',
];
$allowedMimes = $this->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;
}
}