['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) {} }