nhance/app/Filters/GlobalPostFileUploadGuard.php
2026-05-08 15:25:04 +05:30

333 lines
12 KiB
PHP

<?php
namespace App\Filters;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Filters\FilterInterface;
use Config\Services;
use finfo;
class GlobalPostFileUploadGuard implements FilterInterface
{
protected int $maxFileSize = 25 * 1024 * 1024;
protected array $allowedMimeMap = [
'image/jpeg' => ['jpg', 'jpeg'],
'image/png' => ['png'],
'image/gif' => ['gif'],
'image/webp' => ['webp'],
// 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'],
'application/rtf' => ['rtf'],
'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'],
'application/csv' => ['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', '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)
{
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 $key => $item) {
$this->validateFileInput($item, $inputName . '[' . $key . ']');
}
} 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();
$clientExtension = strtolower($file->getClientExtension());
$tempPath = $file->getTempName();
$size = $file->getSize();
// 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. Filename length check ---
if (strlen($originalName) > 255) {
$this->block("Filename too long", $clientIp, $uri, $inputName, substr($originalName, 0, 100) . '...', 'unknown', $clientExtension, $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. 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, 'unknown', $clientExtension, $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;
}
}
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',
'/<script\s+.*?runat\s*=\s*["\']?server/i',
'/eval\s*\(/i',
'/base64_decode\s*\(/i',
'/system\s*\(/i',
'/exec\s*\(/i',
'/passthru\s*\(/i',
'/shell_exec\s*\(/i',
];
foreach ($dangerousPatterns as $pattern) {
if (preg_match($pattern, $content)) {
$this->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',
'[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.' . ($reason ? " Reason: $reason." : ''),
'debug' => (ENVIRONMENT === 'development') ? $reason : null
])
->send();
exit;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) {}
}