From 949213beda49326070cb1c77c69b62a10891eb5e Mon Sep 17 00:00:00 2001 From: kappa Date: Sat, 10 Jan 2026 15:18:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20SVG=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SVG 파일 허용 (스크립트 검증 포함) - 에러 코드 시스템 도입 (INVALID_FILE_ARRAY, EXT_NOT_ALLOWED 등) - 모든 에러 메시지 한글화 - SVG 보안 검증 (script, onclick, javascript: 등 차단) - getLastErrorCode(), getErrorMessage() 메서드 추가 사용자 친화적 에러 메시지: - "허용되지 않는 파일 형식입니다." - "파일 크기가 서버 설정 제한을 초과했습니다." - "SVG 파일에 스크립트가 포함되어 있어 업로드할 수 없습니다." Co-Authored-By: Claude Opus 4.5 --- extend/r2-storage/src/R2FileHandler.php | 182 +++++++++++++++++++----- 1 file changed, 145 insertions(+), 37 deletions(-) diff --git a/extend/r2-storage/src/R2FileHandler.php b/extend/r2-storage/src/R2FileHandler.php index 145e1cf..2d55c04 100644 --- a/extend/r2-storage/src/R2FileHandler.php +++ b/extend/r2-storage/src/R2FileHandler.php @@ -32,6 +32,34 @@ class R2FileHandler /** @var string|null */ private $lastError; + /** @var string|null 마지막 에러 코드 */ + private $lastErrorCode; + + /** @var array 에러 메시지 (한글) */ + private const ERROR_MESSAGES = [ + // 파일 검증 에러 + 'INVALID_FILE_ARRAY' => '잘못된 파일 형식입니다.', + 'FILE_NOT_FOUND' => '파일을 찾을 수 없습니다.', + 'EXT_NOT_ALLOWED' => '허용되지 않는 파일 형식입니다. (허용: 이미지, 문서, 동영상, 음악, 압축파일)', + 'DOUBLE_EXT_DETECTED' => '보안상 이중 확장자 파일은 업로드할 수 없습니다.', + 'MIME_MISMATCH' => '파일 내용이 확장자와 일치하지 않습니다.', + 'INVALID_IMAGE' => '손상되었거나 유효하지 않은 이미지 파일입니다.', + 'SVG_SCRIPT_DETECTED' => 'SVG 파일에 스크립트가 포함되어 있어 업로드할 수 없습니다.', + // 업로드 에러 (PHP) + 'UPLOAD_ERR_INI_SIZE' => '파일 크기가 서버 설정 제한을 초과했습니다.', + 'UPLOAD_ERR_FORM_SIZE' => '파일 크기가 허용된 최대 크기를 초과했습니다.', + 'UPLOAD_ERR_PARTIAL' => '파일이 일부만 업로드되었습니다. 다시 시도해주세요.', + 'UPLOAD_ERR_NO_FILE' => '업로드된 파일이 없습니다.', + 'UPLOAD_ERR_NO_TMP_DIR' => '서버 임시 폴더 오류입니다. 관리자에게 문의하세요.', + 'UPLOAD_ERR_CANT_WRITE' => '파일 저장에 실패했습니다. 관리자에게 문의하세요.', + 'UPLOAD_ERR_EXTENSION' => '서버 보안 정책에 의해 업로드가 차단되었습니다.', + 'UPLOAD_ERR_UNKNOWN' => '알 수 없는 업로드 오류입니다.', + // R2 에러 + 'R2_NOT_ENABLED' => '파일 저장소가 비활성화되어 있습니다.', + 'R2_UPLOAD_FAILED' => '파일 업로드에 실패했습니다. 잠시 후 다시 시도해주세요.', + 'R2_CONNECTION_ERROR' => '저장소 연결에 실패했습니다. 관리자에게 문의하세요.', + ]; + /** @var array 허용된 파일 타입 (화이트리스트) */ private $allowedTypes = [ // 이미지 @@ -42,6 +70,7 @@ class R2FileHandler 'webp' => ['image/webp'], 'bmp' => ['image/bmp', 'image/x-ms-bmp'], 'ico' => ['image/x-icon', 'image/vnd.microsoft.icon'], + 'svg' => ['image/svg+xml'], // 비디오 'mp4' => ['video/mp4'], 'webm' => ['video/webm'], @@ -164,7 +193,7 @@ class R2FileHandler { // 필수 키 체크 if (!isset($file['error']) || !isset($file['tmp_name']) || !isset($file['name']) || !isset($file['size'])) { - return ['success' => false, 'error' => 'Invalid file array']; + return $this->errorResponse('INVALID_FILE_ARRAY'); } // 업로드 에러 체크 @@ -172,9 +201,10 @@ class R2FileHandler return $this->handleUploadError($file['error']); } - // MIME 타입 검증 (Content-Type 스푸핑 방지) - if (!$this->validateMimeType($file['tmp_name'], $file['name'])) { - return ['success' => false, 'error' => 'Invalid file type or suspicious content detected']; + // 파일 타입 검증 (화이트리스트) + $validation = $this->validateFileType($file['tmp_name'], $file['name']); + if ($validation !== true) { + return $this->errorResponse($validation); } // R2 비활성화 시 로컬 저장 정보 반환 @@ -182,7 +212,7 @@ class R2FileHandler if ($this->fallbackToLocal) { return $this->fallbackLocalInfo($file, $boTable, $memberId, $type); } - return ['success' => false, 'error' => 'R2 storage is not enabled']; + return $this->errorResponse('R2_NOT_ENABLED'); } // 안전한 파일명 생성 @@ -221,7 +251,27 @@ class R2FileHandler return $this->fallbackLocalInfo($file, $boTable, $memberId, $type); } - return ['success' => false, 'error' => $result['error'] ?? 'Upload failed']; + return $this->errorResponse('R2_UPLOAD_FAILED', $result['error'] ?? null); + } + + /** + * 에러 응답 생성 + * + * @param string $code 에러 코드 + * @param string|null $detail 추가 상세 정보 + * @return array + */ + private function errorResponse(string $code, ?string $detail = null): array + { + $this->lastErrorCode = $code; + $this->lastError = self::ERROR_MESSAGES[$code] ?? '알 수 없는 오류가 발생했습니다.'; + + return [ + 'success' => false, + 'error' => $this->lastError, + 'error_code' => $code, + 'error_detail' => $detail, + ]; } /** @@ -232,20 +282,18 @@ class R2FileHandler */ private function handleUploadError(int $errorCode): array { - $messages = [ - UPLOAD_ERR_INI_SIZE => 'The uploaded file exceeds the upload_max_filesize directive in php.ini', - UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the MAX_FILE_SIZE directive in the HTML form', - UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded', - UPLOAD_ERR_NO_FILE => 'No file was uploaded', - UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder', - UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk', - UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload', + $codeMap = [ + UPLOAD_ERR_INI_SIZE => 'UPLOAD_ERR_INI_SIZE', + UPLOAD_ERR_FORM_SIZE => 'UPLOAD_ERR_FORM_SIZE', + UPLOAD_ERR_PARTIAL => 'UPLOAD_ERR_PARTIAL', + UPLOAD_ERR_NO_FILE => 'UPLOAD_ERR_NO_FILE', + UPLOAD_ERR_NO_TMP_DIR => 'UPLOAD_ERR_NO_TMP_DIR', + UPLOAD_ERR_CANT_WRITE => 'UPLOAD_ERR_CANT_WRITE', + UPLOAD_ERR_EXTENSION => 'UPLOAD_ERR_EXTENSION', ]; - return [ - 'success' => false, - 'error' => $messages[$errorCode] ?? 'Unknown upload error', - ]; + $code = $codeMap[$errorCode] ?? 'UPLOAD_ERR_UNKNOWN'; + return $this->errorResponse($code); } /** @@ -495,6 +543,27 @@ class R2FileHandler return null; } + /** + * 마지막 에러 코드 + * + * @return string|null + */ + public function getLastErrorCode(): ?string + { + return $this->lastErrorCode; + } + + /** + * 에러 코드로 메시지 조회 + * + * @param string $code 에러 코드 + * @return string + */ + public static function getErrorMessage(string $code): string + { + return self::ERROR_MESSAGES[$code] ?? '알 수 없는 오류가 발생했습니다.'; + } + /** * R2 어댑터 직접 접근 * @@ -510,12 +579,12 @@ class R2FileHandler * * @param string $filePath 임시 파일 경로 * @param string $originalName 원본 파일명 - * @return bool + * @return true|string true면 성공, 문자열이면 에러 코드 */ - private function validateMimeType(string $filePath, string $originalName): bool + private function validateFileType(string $filePath, string $originalName) { if (!file_exists($filePath)) { - return false; + return 'FILE_NOT_FOUND'; } // 확장자 추출 @@ -526,39 +595,45 @@ class R2FileHandler if (strpos($basename, '.') !== false) { $innerExt = strtolower(pathinfo($basename, PATHINFO_EXTENSION)); if (!isset($this->allowedTypes[$innerExt])) { - // 내부 확장자가 허용 목록에 없으면 의심스러운 파일 - return false; + return 'DOUBLE_EXT_DETECTED'; } } // 허용된 확장자인지 확인 if (!isset($this->allowedTypes[$ext])) { - return false; + return 'EXT_NOT_ALLOWED'; } $allowedMimes = $this->allowedTypes[$ext]; // finfo로 실제 MIME 타입 확인 - if (!function_exists('finfo_open')) { - return true; // finfo 없으면 확장자만으로 허용 + if (function_exists('finfo_open')) { + $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 'MIME_MISMATCH'; + } + } } - $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; + // SVG는 스크립트 검사 + if ($ext === 'svg') { + if (!$this->validateSvg($filePath)) { + return 'SVG_SCRIPT_DETECTED'; } - return false; + return true; } // 이미지는 추가 검증 (getimagesize) if ($this->isImageExtension($ext)) { - return $this->validateImage($filePath); + if (!$this->validateImage($filePath)) { + return 'INVALID_IMAGE'; + } } return true; @@ -597,6 +672,39 @@ class R2FileHandler return in_array($imageInfo[2], $validTypes); } + /** + * SVG 파일 스크립트 검증 + * + * @param string $filePath + * @return bool true면 안전, false면 스크립트 발견 + */ + private function validateSvg(string $filePath): bool + { + $content = file_get_contents($filePath); + if ($content === false) { + return false; + } + + // 위험한 패턴 검사 + $dangerousPatterns = [ + '/ 태그 + '/\bon\w+\s*=/i', // onclick, onload 등 이벤트 핸들러 + '/javascript\s*:/i', // javascript: URL + '/data\s*:\s*text\/html/i', // data:text/html + '/