From 87644e3565cbc2ca9161421bf2edc80ef4e5d1e1 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Fri, 31 Jan 2025 10:29:33 -0500 Subject: [PATCH] Add FileSizeCheck middleware; add middleware unit tests; add environment variables for file size limits --- ProcessMaker/Http/Kernel.php | 2 + .../Http/Middleware/FileSizeCheck.php | 168 ++++++++++++++++ config/app.php | 10 +- tests/unit/FileSizeCheckTest.php | 188 ++++++++++++++++++ 4 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 ProcessMaker/Http/Middleware/FileSizeCheck.php create mode 100644 tests/unit/FileSizeCheckTest.php diff --git a/ProcessMaker/Http/Kernel.php b/ProcessMaker/Http/Kernel.php index 193beb528b..c7d6d7ab8c 100644 --- a/ProcessMaker/Http/Kernel.php +++ b/ProcessMaker/Http/Kernel.php @@ -22,6 +22,7 @@ class Kernel extends HttpKernel Middleware\TrustProxies::class, Middleware\BrowserCache::class, ServerTimingMiddleware::class, + Middleware\FileSizeCheck::class, ]; /** @@ -86,6 +87,7 @@ class Kernel extends HttpKernel 'no-cache' => Middleware\NoCache::class, 'admin' => Middleware\IsAdmin::class, 'etag' => Middleware\Etag\HandleEtag::class, + 'file_size_check' => Middleware\FileSizeCheck::class, ]; /** diff --git a/ProcessMaker/Http/Middleware/FileSizeCheck.php b/ProcessMaker/Http/Middleware/FileSizeCheck.php new file mode 100644 index 0000000000..b8ad277d7a --- /dev/null +++ b/ProcessMaker/Http/Middleware/FileSizeCheck.php @@ -0,0 +1,168 @@ +allFiles()) { + try { + $this->validateFiles($request); + } catch (ValidationException $e) { + return response()->json([ + 'message' => $e->errors()['file'][0], + ])->setStatusCode(422); + } catch (Exception $e) { + return response()->json([ + 'message' => $e->getMessage(), + ])->setStatusCode(422); + } + } + + return $next($request); + } + + /** + * Handle tasks after the response is sent. + */ + public function terminate(Request $request, $response): void + { + // Suppress unused parameter warning. + unset($request); + + if ($response instanceof Response) { + $response->headers->set('X-FileSize-Checked', 'true'); + } + } + + /** + * Get the maximum file size allowed + * + * @param string $filetype + * @return string + */ + protected function getMaxFileSize($filetype) + { + // Define image types + $imageMimeTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'image/bmp', + 'image/tiff', + ]; + + // Define document types + $documentMimeTypes = [ + 'text/plain', + 'application/rtf', + 'text/markdown', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/pdf', + 'text/csv', + 'text/html', + 'application/xml', + 'text/xml', + 'application/json', + ]; + + if (in_array($filetype, $imageMimeTypes)) { + return config('app.settings.img_max_filesize_limit'); + } elseif (in_array($filetype, $documentMimeTypes)) { + return config('app.settings.doc_max_filesize_limit'); + } else { + return config('app.settings.max_filesize_limit') ?? ini_get('upload_max_filesize'); + } + } + + /** + * Convert PHP ini size value to bytes + * + * @param string $size + * @return int + */ + private function convertToBytes($size) + { + $unit = strtoupper(substr($size, -1)); + $value = (int) substr($size, 0, -1); + + switch ($unit) { + case 'G': + $value *= 1024 * 1024 * 1024; // Convert GB to bytes + break; + case 'M': + $value *= 1024 * 1024; // Convert MB to bytes + break; + case 'K': + $value *= 1024; // Convert KB to bytes + break; + default: + return (int) $size; // Already in bytes + } + + return $value; + } + + /** + * Recursively validate files + * + * @param Request $request + * @throws ValidationException + */ + private function validateFiles($request) + { + $files = $request->allFiles(); + $totalSize = (int) $request->get('totalSize', 0); + $maxSize = config('app.settings.max_filesize_limit'); + $maxSizeInBytes = $this->convertToBytes($maxSize); + + // Check total size first if it exists (using a general max_filesize_limit from env) + if ($totalSize > 0 && $totalSize > $maxSizeInBytes) { + throw ValidationException::withMessages([ + 'file' => ['The total upload size is too large. Maximum allowed size is ' . $maxSize], + ]); + } + + // If no total size, check individual files + foreach ($files as $file) { + if (!$file->isValid()) { + throw ValidationException::withMessages([ + 'file' => ['The file upload was not successful.'], + ]); + } + + // Get max filesize depending on filetype + $fileType = $file->getClientMimeType(); + $maxFileSize = $this->getMaxFileSize($fileType); + $maxFileSizeInBytes = $this->convertToBytes($maxFileSize); + + if ($totalSize == 0 && $file->getSize() > $maxFileSizeInBytes) { + throw ValidationException::withMessages([ + 'file' => ['The file is too large. Maximum allowed size is ' . $maxFileSize], + ]); + } + } + } +} diff --git a/config/app.php b/config/app.php index 2ca5ef4050..77f8eacf20 100644 --- a/config/app.php +++ b/config/app.php @@ -153,6 +153,14 @@ // Path to site-wide favicon 'favicon_path' => env('FAVICON_PATH', '/img/favicon.svg'), + // Maximum file size for images to be set as default (in bytes) (5MB) + 'img_max_filesize_limit' => env('IMG_MAX_FILESIZE_LIMIT', '5M'), + + // Maximum file size for documents to be set as default (in bytes) (10MB) + 'doc_max_filesize_limit' => env('DOC_MAX_FILESIZE_LIMIT', '10M'), + + // Maximum file size for all files to be set as default (in bytes) (10MB) + 'max_filesize_limit' => env('MAX_FILESIZE_LIMIT', '10M'), ], // Turn on/off the recommendation engine @@ -272,7 +280,7 @@ 'custom_executors' => env('CUSTOM_EXECUTORS', false), 'prometheus_namespace' => env('PROMETHEUS_NAMESPACE', 'processmaker'), - + 'server_timing' => [ 'enabled' => env('SERVER_TIMING_ENABLED', true), 'min_package_time' => env('SERVER_TIMING_MIN_PACKAGE_TIME', 5), // Minimum time in milliseconds diff --git a/tests/unit/FileSizeCheckTest.php b/tests/unit/FileSizeCheckTest.php new file mode 100644 index 0000000000..e1b5a433ee --- /dev/null +++ b/tests/unit/FileSizeCheckTest.php @@ -0,0 +1,188 @@ +any(self::TEST_ROUTE, function () { + return response()->json(['message' => $this->response], 200); + }); + + $this->user = User::factory()->create([ + 'password' => bcrypt('password'), + 'is_administrator' => true, + ]); + + $middlewareMock = $this->getMockBuilder(FileSizeCheck::class) + ->onlyMethods(['getMaxFileSize']) + ->getMock(); + + $middlewareMock->expects($this->any()) + ->method('getMaxFileSize') + ->with($this->anything()) + ->willReturn('10M'); + + $this->app->instance(FileSizeCheck::class, $middlewareMock); + } + + public function testNoFilesPassesThrough() + { + $response = $this->postJson(self::TEST_ROUTE); + $response->assertStatus(200); + $response->assertJson(['message' => $this->response]); + } + + public function testValidFileUpload() + { + $file = UploadedFile::fake()->create('test.pdf', 500); // 500 KB + $response = $this->postJson(self::TEST_ROUTE, [ + 'file' => $file, + ]); + + $response->assertStatus(200); + $response->assertJson(['message' => $this->response]); + } + + public function testLargeFileRejected() + { + // Arrange. + $mockFile = $this->createMock(UploadedFile::class); + $mockFile->method('getSize')->willReturn(11 * 1024 * 1024); // 11 MB + $mockFile->method('isValid')->willReturn(true); + + // Act. + $response = $this->postJson(self::TEST_ROUTE, [ + 'file' => $mockFile, + ]); + + // Assert. + $response->assertStatus(422); + $response->assertJson([ + 'message' => 'The file is too large. Maximum allowed size is 10M', + ]); + } + + public function testInvalidFileUpload() + { + // Mock of an invalid file using PHPUnit. + $mockFile = $this->createMock(UploadedFile::class); + $mockFile->method('isValid')->willReturn(false); // Simulate invalid file. + $mockFile->method('getSize')->willReturn(500); // Simulate file size. + $mockFile->method('getClientOriginalName')->willReturn('test.pdf'); + + // Act. + $response = $this->postJson(self::TEST_ROUTE, [ + 'file' => $mockFile, + ]); + + // Assert. + $response->assertStatus(422); + $response->assertJson([ + 'message' => 'The file upload was not successful.', + ]); + } + + public function testTotalSizeExceedsLimit() + { + $file1 = UploadedFile::fake()->create('file1.pdf', 5000); // 5 MB. + $file2 = UploadedFile::fake()->create('file2.pdf', 6000); // 6 MB. + $totalSize = $file1->getSize() + $file2->getSize(); + + $response = $this->postJson(self::TEST_ROUTE, [ + 'file1' => $file1, + 'file2' => $file2, + 'totalSize' => $totalSize, // 11 MB (exceeds limit). + ]); + + $response->assertStatus(422); + $response->assertJson([ + 'message' => 'The total upload size is too large. Maximum allowed size is 10M', + ]); + } + + public function testTotalSizeWithinLimit() + { + ini_set('upload_max_filesize', '5M'); // 5 MB + + $file1 = UploadedFile::fake()->create('file1.pdf', 2000); // 2 MB + $file2 = UploadedFile::fake()->create('file2.pdf', 1000); // 1 MB + + $response = $this->postJson(self::TEST_ROUTE, [ + 'file1' => $file1, + 'file2' => $file2, + 'totalSize' => 3000, // 3 MB + ]); + + $response->assertStatus(200); + $response->assertJson(['message' => $this->response]); + } + + /** + * Test if the middleware is applied to API routes. + */ + public function testFileSizeCheckMiddlewareIsAppliedToApiRoutes() + { + $processRequest = ProcessRequest::factory()->create(); + + $response = $this->apiCall( + 'POST', + route('api.requests.files.store', [$processRequest->id]), + ['file' => UploadedFile::fake()->create('test.pdf', 500)] + ); + + // Verify that the header added by the middleware is present. + $response->assertOk(); + $response->assertHeader('X-FileSize-Checked', 'true'); + } + + /** + * Test if the middleware is applied to Web routes. + */ + public function testFileSizeCheckMiddlewareIsAppliedToWebRoutes() + { + $response = $this->webCall('GET', route('processes.index')); + + $response->assertOk(); + $response->assertHeader('X-FileSize-Checked', 'true'); + } + + /** + * Test if the middleware is applied to package routes. + */ + public function testFileSizeCheckMiddlewareIsAppliedToPackageRoutes() + { + $hasPackage = \hasPackage('package-files'); + + if (!$hasPackage) { + $this->markTestSkipped('The package is not installed.'); + } + + \ProcessMaker\Package\Files\AddPublicFilesProcess::call(); + + $response = $this->apiCall( + 'POST', + route('api.file-manager.store'), + ['file' => UploadedFile::fake()->create('test.pdf', 500)] + ); + + $response->assertOk(); + $response->assertHeader('X-FileSize-Checked', 'true'); + } +}