Skip to content

FOUR-21235 | Enforce File Upload Limit When Using Chunked File Upload Component #7975

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ProcessMaker/Http/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class Kernel extends HttpKernel
Middleware\TrustProxies::class,
Middleware\BrowserCache::class,
ServerTimingMiddleware::class,
Middleware\FileSizeCheck::class,
];

/**
Expand Down Expand Up @@ -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,
];

/**
Expand Down
168 changes: 168 additions & 0 deletions ProcessMaker/Http/Middleware/FileSizeCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<?php

namespace ProcessMaker\Http\Middleware;

use Closure;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;

class FileSizeCheck
{
/**
* Handle an incoming request.
*
* @param Request $request
* @param Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if ($request->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],
]);
}
}
}
}
10 changes: 9 additions & 1 deletion config/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
188 changes: 188 additions & 0 deletions tests/unit/FileSizeCheckTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<?php

namespace Tests\Unit\Middleware;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Route;
use ProcessMaker\Http\Middleware\FileSizeCheck;
use ProcessMaker\Models\ProcessRequest;
use ProcessMaker\Models\User;
use Tests\Feature\Shared\RequestHelper;
use Tests\TestCase;

class FileSizeCheckTest extends TestCase
{
use RequestHelper;

private string $response = 'OK';

private const TEST_ROUTE = '/upload';

protected function setUp(): void
{
parent::setUp();
Route::middleware(FileSizeCheck::class)->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');
}
}
Loading