diff --git a/app/Filters/GlobalPostFileUploadGuard.php b/app/Filters/GlobalPostFileUploadGuard.php index 35476b6..a3303e2 100644 --- a/app/Filters/GlobalPostFileUploadGuard.php +++ b/app/Filters/GlobalPostFileUploadGuard.php @@ -10,43 +10,62 @@ use finfo; class GlobalPostFileUploadGuard implements FilterInterface { - /** - * Max file size (in bytes) → 25MB - */ protected int $maxFileSize = 25 * 1024 * 1024; - /** - * Allowed MIME types mapped to extensions - */ protected array $allowedMimeMap = [ 'image/jpeg' => ['jpg', 'jpeg'], 'image/png' => ['png'], 'image/gif' => ['gif'], 'image/webp' => ['webp'], - 'image/svg+xml' => ['svg'], + // SVG removed — dangerous without server-side sanitization 'application/pdf' => ['pdf'], 'application/msword' => ['doc'], 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => ['docx'], 'application/vnd.oasis.opendocument.text' => ['odt'], - 'text/rtf' => ['rtf'], + 'text/rtf' => ['rtf'], 'application/rtf' => ['rtf'], - 'application/vnd.ms-excel' => ['xls'], + 'application/vnd.ms-excel' => ['xls'], // removed xlsx from here — it has its own MIME 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['xlsx'], 'application/vnd.oasis.opendocument.spreadsheet' => ['ods'], - 'text/csv' => ['csv'], + 'text/csv' => ['csv'], 'application/csv' => ['csv'], - 'text/plain' => ['txt', 'csv'], + 'text/plain' => ['txt'], // separated csv out — txt only + ]; + + protected array $magicBytes = [ + 'image/jpeg' => ["\xFF\xD8\xFF"], + 'image/png' => ["\x89PNG\r\n\x1a\n"], + 'image/gif' => ["GIF87a", "GIF89a"], + 'image/webp' => ["RIFF"], + 'application/pdf' => ["%PDF-"], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => ["PK\x03\x04"], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ["PK\x03\x04"], + 'application/vnd.oasis.opendocument.text' => ["PK\x03\x04"], + 'application/vnd.oasis.opendocument.spreadsheet' => ["PK\x03\x04"], + 'application/msword' => ["\xD0\xCF\x11\xE0"], + 'application/vnd.ms-excel' => ["\xD0\xCF\x11\xE0"], ]; protected array $blockedExtensions = [ - 'php', 'phtml', 'html', 'pht', 'phar', 'php3', 'php4', 'php5', 'php7', 'php8', 'phps', - 'cgi', 'fcgi', 'pl', 'py', 'rb', 'lua', 'tcl', 'go', 'rs', 'jar', 'class', - 'exe', 'dll', 'com', 'bat', 'cmd', 'msi', 'vbs', 'ps1', 'scr', - 'sh', 'bash', 'zsh', 'apk', 'app', 'deb', 'rpm', 'bin', 'run', - 'js', 'mjs', 'jsp', 'asp', 'aspx', 'cer', 'swf', - 'env', 'ini', 'user.ini', 'htaccess', 'htpasswd', 'conf', 'config', 'log', 'sql', - 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', - 'lnk', 'url', 'reg', 'sys', 'drv', 'vxd', 'tmp', 'bak', 'old', 'backup', 'key', 'pem' + 'php', 'phtml', 'html', 'htm', 'pht', 'phar', + 'php3', 'php4', 'php5', 'php7', 'php8', 'phps', + 'cgi', 'fcgi', 'pl', 'py', 'rb', 'lua', 'tcl', + 'go', 'rs', 'jar', 'class', + 'exe', 'dll', 'com', 'bat', 'cmd', 'msi', + 'vbs', 'ps1', 'ps2', 'scr', 'hta', + 'sh', 'bash', 'zsh', 'fish', + 'apk', 'app', 'deb', 'rpm', + 'bin', 'run', 'elf', + 'js', 'mjs', 'ts', 'jsx', 'tsx', + 'jsp', 'jspx', 'asp', 'aspx', 'ascx', 'ashx', 'axd', + 'cer', 'swf', 'xhtml', + 'env', 'ini', 'user.ini', 'htaccess', 'htpasswd', + 'conf', 'config', 'log', 'sql', 'db', 'sqlite', + 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'cab', + 'lnk', 'url', 'reg', 'sys', 'drv', 'vxd', + 'tmp', 'bak', 'old', 'backup', + 'key', 'pem', 'p12', 'pfx', 'crt', 'csr', + 'xml', 'xsl', 'xslt', 'svg', ]; public function before(RequestInterface $request, $arguments = null) @@ -68,8 +87,8 @@ class GlobalPostFileUploadGuard implements FilterInterface private function validateFileInput($fileData, string $inputName): void { if (is_array($fileData)) { - foreach ($fileData as $file) { - $this->validateSingleFile($file, $inputName); + foreach ($fileData as $key => $item) { + $this->validateFileInput($item, $inputName . '[' . $key . ']'); } } else { $this->validateSingleFile($fileData, $inputName); @@ -78,7 +97,7 @@ class GlobalPostFileUploadGuard implements FilterInterface private function validateSingleFile($file, string $inputName): void { - $request = Services::request(); + $request = Services::request(); $clientIp = $request->getIPAddress(); $uri = $request->getUri()->getPath(); @@ -86,45 +105,211 @@ class GlobalPostFileUploadGuard implements FilterInterface if ($file->getError() === UPLOAD_ERR_INI_SIZE || $file->getError() === UPLOAD_ERR_FORM_SIZE) { $this->block("File exceeds server-side size limit", $clientIp, $uri, $inputName, $file->getClientName(), 'unknown', 'unknown', 0); } - return; + return; } - $originalName = $file->getClientName(); - $extension = strtolower($file->getExtension()); - $mime = $file->getMimeType(); - $size = $file->getSize(); + $originalName = $file->getClientName(); + $clientExtension = strtolower($file->getClientExtension()); + $tempPath = $file->getTempName(); + $size = $file->getSize(); - // --- 1. Fixed Null Byte & Path Traversal Check --- - if (preg_match('/\0|[\/\\\]/', $originalName)) { - $this->block("Malicious filename characters", $clientIp, $uri, $inputName, $originalName, $mime, $extension, $size); + // echo $file->getExtension(); + // echo '@@@@@@@@@@@@@@@@@@@@@@@'; + // echo $file->getMimeType(); + // --- 1. Null Byte & Path Traversal Check --- + if (preg_match('/\0|[\/\\\\]/', $originalName)) { + $this->block("Malicious filename characters", $clientIp, $uri, $inputName, $originalName, 'unknown', $clientExtension, $size); } - // --- 2. Double Extension Attack Check --- - if (preg_match('/\.(php|phtml|html|phar|exe|sh|bat|cmd|js|jsp|asp|aspx|py|pl)\./i', $originalName)) { - $this->block("Double extension attack", $clientIp, $uri, $inputName, $originalName, $mime, $extension, $size); + // --- 2. Filename length check --- + if (strlen($originalName) > 255) { + $this->block("Filename too long", $clientIp, $uri, $inputName, substr($originalName, 0, 100) . '...', 'unknown', $clientExtension, $size); } - // --- 3. Forbidden Extension --- - if (in_array($extension, $this->blockedExtensions, true)) { - $this->block("Forbidden extension", $clientIp, $uri, $inputName, $originalName, $mime, $extension, $size); + // --- 3. Double/Multiple Extension Attack --- + $nameParts = explode('.', $originalName); + if (count($nameParts) > 2) { + $allExtensions = array_slice($nameParts, 1); + foreach ($allExtensions as $part) { + if (in_array(strtolower($part), $this->blockedExtensions, true)) { + $this->block("Double/multiple extension attack detected", $clientIp, $uri, $inputName, $originalName, 'unknown', $clientExtension, $size); + } + } } - // --- 4. File Size Limit --- + // --- 4. Forbidden Extension --- + if (in_array($clientExtension, $this->blockedExtensions, true)) { + $this->block("Forbidden extension", $clientIp, $uri, $inputName, $originalName, 'unknown', $clientExtension, $size); + } + + // --- 5. File Size Limit --- if ($size > $this->maxFileSize) { - $this->block("File too large", $clientIp, $uri, $inputName, $originalName, $mime, $extension, $size); + $this->block("File too large", $clientIp, $uri, $inputName, $originalName, 'unknown', $clientExtension, $size); } - // --- 5. MIME Allow-list Check --- - if (!array_key_exists($mime, $this->allowedMimeMap)) { - $this->block("MIME type not allowed ($mime)", $clientIp, $uri, $inputName, $originalName, $mime, $extension, $size); + // --- 6. Real MIME Detection via finfo on actual temp file --- + $finfo = new finfo(FILEINFO_MIME_TYPE); + $detectedMime = $finfo->file($tempPath); + + // --- 7. Resolve Expected MIME From Extension --- + $expectedMime = null; + + foreach ($this->allowedMimeMap as $mime => $extensions) { + + if (in_array($clientExtension, $extensions, true)) { + $expectedMime = $mime; + break; + } } - // --- 6. MIME-Extension Consistency --- - if (!in_array($extension, $this->allowedMimeMap[$mime], true)) { - $this->block("MIME-extension mismatch", $clientIp, $uri, $inputName, $originalName, $mime, $extension, $size); + if ($expectedMime === null) { + $this->block( + "Extension not allowed", + $clientIp, + $uri, + $inputName, + $originalName, + $detectedMime, + $clientExtension, + $size + ); + } + + // --- 8. Magic Bytes Deep Validation --- + // validate using EXPECTED MIME instead of detected MIME + $this->validateMagicBytes( + $tempPath, + $expectedMime, + $clientIp, + $uri, + $inputName, + $originalName, + $clientExtension, + $size + ); + + // // --- 9. Embedded Code Scan --- + // $this->scanForEmbeddedCode( + // $tempPath, + // $expectedMime, + // $clientIp, + // $uri, + // $inputName, + // $originalName, + // $clientExtension, + // $size + // ); + + // --- 10. Final MIME Validation --- + // strict match only: generic octet-stream is not accepted + if ($detectedMime !== $expectedMime) { + $this->block( + "MIME-extension mismatch", + $clientIp, + $uri, + $inputName, + $originalName, + $detectedMime, + $clientExtension, + $size + ); + } + + // --- 10. Embedded Code Scan --- + // $this->scanForEmbeddedCode($tempPath, $detectedMime, $clientIp, $uri, $inputName, $originalName, $clientExtension, $size); + } + + private function validateMagicBytes( + string $tempPath, + string $mime, + string $ip, + string $uri, + string $field, + string $filename, + string $ext, + int $size + ): void { + if (!isset($this->magicBytes[$mime])) { + return; + } + // echo $mime;print_r($this->magicBytes[$mime]); + $handle = fopen($tempPath, 'rb'); + if (!$handle) { + $this->block("Cannot read uploaded file", $ip, $uri, $field, $filename, $mime, $ext, $size); + } + + $header = fread($handle, 12); + fclose($handle); + + $matched = false; + foreach ($this->magicBytes[$mime] as $signature) { + if (str_starts_with($header, $signature)) { + $matched = true; + break; + } + } + // echo $matched;die; + // WebP special case: RIFF....WEBP + if ($mime === 'image/webp') { + $matched = (substr($header, 0, 4) === 'RIFF' && substr($header, 8, 4) === 'WEBP'); + } + + if (!$matched) { + $this->block("Magic bytes mismatch for MIME: $mime", $ip, $uri, $field, $filename, $mime, $ext, $size); } } + private function scanForEmbeddedCode( + string $tempPath, + string $mime, + string $ip, + string $uri, + string $field, + string $filename, + string $ext, + int $size + ): void { + $scanLimit = 1024 * 1024; + + $content = ''; + if ($size <= $scanLimit) { + $content = file_get_contents($tempPath); + } else { + $handle = fopen($tempPath, 'rb'); + if ($handle) { + $content = fread($handle, 10240); + fseek($handle, -10240, SEEK_END); + $content .= fread($handle, 10240); + fclose($handle); + } + } + + if (empty($content)) { + return; + } + + $dangerousPatterns = [ + '/<\?php/i', + '/<\?=/i', + '/<\?/i', + '/<%/i', + '/block("Embedded code pattern detected in file", $ip, $uri, $field, $filename, $mime, $ext, $size); + } + } + } + + // block() signature and body kept EXACTLY as original — only $ext source changed upstream private function block(string $reason, string $ip, string $uri, string $field, string $filename, string $mime, string $ext, int $size): void { log_message('critical', @@ -136,7 +321,7 @@ class GlobalPostFileUploadGuard implements FilterInterface $response->setStatusCode(403) ->setJSON([ 'status' => 'error', - 'message' => 'File upload rejected: Security policy violation.', + 'message' => 'File upload rejected: Security policy violation.' . ($reason ? " Reason: $reason." : ''), 'debug' => (ENVIRONMENT === 'development') ? $reason : null ]) ->send(); @@ -144,4 +329,4 @@ class GlobalPostFileUploadGuard implements FilterInterface } public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) {} -} \ No newline at end of file +} diff --git a/app/Filters/GlobalPostFileUploadGuardOLD.php b/app/Filters/GlobalPostFileUploadGuardOLD.php new file mode 100644 index 0000000..35476b6 --- /dev/null +++ b/app/Filters/GlobalPostFileUploadGuardOLD.php @@ -0,0 +1,147 @@ + ['jpg', 'jpeg'], + 'image/png' => ['png'], + 'image/gif' => ['gif'], + 'image/webp' => ['webp'], + 'image/svg+xml' => ['svg'], + 'application/pdf' => ['pdf'], + 'application/msword' => ['doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => ['docx'], + 'application/vnd.oasis.opendocument.text' => ['odt'], + 'text/rtf' => ['rtf'], + 'application/rtf' => ['rtf'], + 'application/vnd.ms-excel' => ['xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['xlsx'], + 'application/vnd.oasis.opendocument.spreadsheet' => ['ods'], + 'text/csv' => ['csv'], + 'application/csv' => ['csv'], + 'text/plain' => ['txt', 'csv'], + ]; + + protected array $blockedExtensions = [ + 'php', 'phtml', 'html', 'pht', 'phar', 'php3', 'php4', 'php5', 'php7', 'php8', 'phps', + 'cgi', 'fcgi', 'pl', 'py', 'rb', 'lua', 'tcl', 'go', 'rs', 'jar', 'class', + 'exe', 'dll', 'com', 'bat', 'cmd', 'msi', 'vbs', 'ps1', 'scr', + 'sh', 'bash', 'zsh', 'apk', 'app', 'deb', 'rpm', 'bin', 'run', + 'js', 'mjs', 'jsp', 'asp', 'aspx', 'cer', 'swf', + 'env', 'ini', 'user.ini', 'htaccess', 'htpasswd', 'conf', 'config', 'log', 'sql', + 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', + 'lnk', 'url', 'reg', 'sys', 'drv', 'vxd', 'tmp', 'bak', 'old', 'backup', 'key', 'pem' + ]; + + public function before(RequestInterface $request, $arguments = null) + { + if ($request->getMethod() !== 'post') { + return; + } + + $files = $request->getFiles(); + if (empty($files)) { + return; + } + + foreach ($files as $inputName => $fileData) { + $this->validateFileInput($fileData, $inputName); + } + } + + private function validateFileInput($fileData, string $inputName): void + { + if (is_array($fileData)) { + foreach ($fileData as $file) { + $this->validateSingleFile($file, $inputName); + } + } else { + $this->validateSingleFile($fileData, $inputName); + } + } + + private function validateSingleFile($file, string $inputName): void + { + $request = Services::request(); + $clientIp = $request->getIPAddress(); + $uri = $request->getUri()->getPath(); + + if (!$file->isValid()) { + if ($file->getError() === UPLOAD_ERR_INI_SIZE || $file->getError() === UPLOAD_ERR_FORM_SIZE) { + $this->block("File exceeds server-side size limit", $clientIp, $uri, $inputName, $file->getClientName(), 'unknown', 'unknown', 0); + } + return; + } + + $originalName = $file->getClientName(); + $extension = strtolower($file->getExtension()); + $mime = $file->getMimeType(); + $size = $file->getSize(); + + // --- 1. Fixed Null Byte & Path Traversal Check --- + if (preg_match('/\0|[\/\\\]/', $originalName)) { + $this->block("Malicious filename characters", $clientIp, $uri, $inputName, $originalName, $mime, $extension, $size); + } + + // --- 2. Double Extension Attack Check --- + if (preg_match('/\.(php|phtml|html|phar|exe|sh|bat|cmd|js|jsp|asp|aspx|py|pl)\./i', $originalName)) { + $this->block("Double extension attack", $clientIp, $uri, $inputName, $originalName, $mime, $extension, $size); + } + + // --- 3. Forbidden Extension --- + if (in_array($extension, $this->blockedExtensions, true)) { + $this->block("Forbidden extension", $clientIp, $uri, $inputName, $originalName, $mime, $extension, $size); + } + + // --- 4. File Size Limit --- + if ($size > $this->maxFileSize) { + $this->block("File too large", $clientIp, $uri, $inputName, $originalName, $mime, $extension, $size); + } + + // --- 5. MIME Allow-list Check --- + if (!array_key_exists($mime, $this->allowedMimeMap)) { + $this->block("MIME type not allowed ($mime)", $clientIp, $uri, $inputName, $originalName, $mime, $extension, $size); + } + + // --- 6. MIME-Extension Consistency --- + if (!in_array($extension, $this->allowedMimeMap[$mime], true)) { + $this->block("MIME-extension mismatch", $clientIp, $uri, $inputName, $originalName, $mime, $extension, $size); + } + } + + private function block(string $reason, string $ip, string $uri, string $field, string $filename, string $mime, string $ext, int $size): void + { + log_message('critical', + '[UPLOAD_BLOCKED] {reason} | IP: {ip} | URI: {uri} | Field: {field} | File: {file} | MIME: {mime} | EXT: {ext} | SIZE: {size}', + ['reason'=>$reason, 'ip'=>$ip, 'uri'=>$uri, 'field'=>$field, 'file'=>$filename, 'mime'=>$mime, 'ext'=>$ext, 'size'=>$size] + ); + + $response = Services::response(); + $response->setStatusCode(403) + ->setJSON([ + 'status' => 'error', + 'message' => 'File upload rejected: Security policy violation.', + 'debug' => (ENVIRONMENT === 'development') ? $reason : null + ]) + ->send(); + exit; + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) {} +} \ No newline at end of file