diff --git a/config/formforge.php b/config/formforge.php index db1225a..a85bbe1 100644 --- a/config/formforge.php +++ b/config/formforge.php @@ -225,6 +225,14 @@ 'direct' => [ 'signature_ttl_seconds' => env('FORMFORGE_DIRECT_SIGNATURE_TTL', 900), ], + + 'antivirus' => [ + 'enabled' => env('FORMFORGE_CLAMAV_ENABLED', false), + 'endpoint' => env('FORMFORGE_CLAMAV_ENDPOINT'), + 'username' => env('FORMFORGE_CLAMAV_USERNAME'), + 'password' => env('FORMFORGE_CLAMAV_PASSWORD'), + 'timeout' => env('FORMFORGE_CLAMAV_TIMEOUT', 30), + ], ], /* diff --git a/lang/en/messages.php b/lang/en/messages.php index f94c169..9f3c5b2 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -26,6 +26,11 @@ 'staged_only_file_fields' => 'Only file fields can stage uploads.', 'staged_upload_max_size' => 'Staged upload exceeds max_size [:max] bytes.', 'staged_upload_accepted_types_mismatch' => 'Staged upload does not match accepted file types.', + 'upload_max_total_size' => 'The combined size of uploaded files exceeds the configured maximum.', + 'upload_max_files' => 'The number of uploaded files exceeds the configured maximum.', + 'upload_antivirus_endpoint_missing' => 'The antivirus scanner endpoint is not configured.', + 'upload_antivirus_scan_failed' => 'The uploaded file could not be scanned for viruses.', + 'upload_antivirus_infected' => 'The uploaded file was rejected because it contains malware.', 'upload_token_form_version_mismatch' => 'Upload token does not match the targeted form version.', 'upload_token_field_mismatch' => 'Upload token does not match the targeted file field.', 'upload_token_ownership_mismatch' => 'Upload token ownership mismatch.', diff --git a/lang/fr/messages.php b/lang/fr/messages.php index 3385327..4e8d0b4 100644 --- a/lang/fr/messages.php +++ b/lang/fr/messages.php @@ -26,6 +26,11 @@ 'staged_only_file_fields' => 'Seuls les champs fichier peuvent utiliser le staging.', 'staged_upload_max_size' => 'Le fichier en staging dépasse max_size [:max] octets.', 'staged_upload_accepted_types_mismatch' => 'Le fichier en staging ne correspond pas aux types acceptés.', + 'upload_max_total_size' => 'La taille cumulée des fichiers uploadés dépasse la limite configurée.', + 'upload_max_files' => 'Le nombre de fichiers uploadés dépasse la limite configurée.', + 'upload_antivirus_endpoint_missing' => 'Le point de terminaison de l’analyse antivirus n’est pas configuré.', + 'upload_antivirus_scan_failed' => 'Le fichier uploadé n’a pas pu être analysé par l’antivirus.', + 'upload_antivirus_infected' => 'Le fichier uploadé a été refusé car il contient un logiciel malveillant.', 'upload_token_form_version_mismatch' => 'Le token d’upload ne correspond pas à la version du formulaire ciblée.', 'upload_token_field_mismatch' => 'Le token d’upload ne correspond pas au champ fichier ciblé.', 'upload_token_ownership_mismatch' => 'Le propriétaire du token d’upload ne correspond pas.', diff --git a/src/Definition/FieldBlueprint.php b/src/Definition/FieldBlueprint.php index d3b34fa..f28d0bf 100644 --- a/src/Definition/FieldBlueprint.php +++ b/src/Definition/FieldBlueprint.php @@ -62,6 +62,8 @@ class FieldBlueprint private ?int $maxFiles = null; + private ?int $maxTotalSize = null; + private ?string $storageDisk = null; private ?string $storageDirectory = null; @@ -141,6 +143,7 @@ public static function fromSchemaArray(array $schema): self } $field->maxSize = isset($schema['max_size']) ? (int) $schema['max_size'] : null; $field->maxFiles = isset($schema['max_files']) ? (int) $schema['max_files'] : null; + $field->maxTotalSize = isset($schema['max_total_size']) ? (int) $schema['max_total_size'] : null; if (is_array($schema['storage'] ?? null)) { $field->storageDisk = isset($schema['storage']['disk']) ? trim((string) $schema['storage']['disk']) : null; @@ -402,6 +405,17 @@ public function maxFiles(int $maxFiles): self return $this; } + public function maxTotalSize(int $maxTotalSize): self + { + if ($maxTotalSize <= 0) { + throw new InvalidFieldDefinitionException("maxTotalSize must be positive on field [{$this->name}]."); + } + + $this->maxTotalSize = $maxTotalSize; + + return $this; + } + public function storageDisk(string $storageDisk): self { $storageDisk = trim($storageDisk); @@ -491,6 +505,11 @@ public function maxFilesValue(): ?int return $this->maxFiles; } + public function maxTotalSizeValue(): ?int + { + return $this->maxTotalSize; + } + public function storageDiskValue(): ?string { return $this->storageDisk; @@ -611,6 +630,10 @@ public function toSchemaArray(string $formKey, string $version): array $schema['max_files'] = $this->maxFiles; } + if ($this->maxTotalSize !== null) { + $schema['max_total_size'] = $this->maxTotalSize; + } + $schema['storage'] = [ 'disk' => $this->storageDisk, 'directory' => $this->storageDirectory, diff --git a/src/FormForgeServiceProvider.php b/src/FormForgeServiceProvider.php index 1b7e628..5de70b9 100644 --- a/src/FormForgeServiceProvider.php +++ b/src/FormForgeServiceProvider.php @@ -47,6 +47,8 @@ use EvanSchleret\FormForge\Submissions\StagedUploadService; use EvanSchleret\FormForge\Submissions\UploadManager; use EvanSchleret\FormForge\Support\DefaultFormPublicLinkResolver; +use EvanSchleret\FormForge\Support\Antivirus\ClamAvRestScanner; +use EvanSchleret\FormForge\Support\Antivirus\FileScanner; use EvanSchleret\FormForge\Support\FormPublicLinkResolver; use Illuminate\Support\ServiceProvider; @@ -89,11 +91,18 @@ public function register(): void ), ); $this->app->singleton(DraftStateService::class, static fn (): DraftStateService => new DraftStateService()); - $this->app->singleton(StagedUploadService::class, static fn (): StagedUploadService => new StagedUploadService()); + $this->app->singleton(FileScanner::class, static fn (): FileScanner => new ClamAvRestScanner()); + $this->app->singleton( + StagedUploadService::class, + fn (): StagedUploadService => new StagedUploadService( + scanner: $this->app->make(FileScanner::class), + ), + ); $this->app->singleton( UploadManager::class, fn (): UploadManager => new UploadManager( stagedUploads: $this->app->make(StagedUploadService::class), + scanner: $this->app->make(FileScanner::class), ), ); $this->app->singleton(HttpOptionsResolver::class, static fn (): HttpOptionsResolver => new HttpOptionsResolver()); diff --git a/src/Submissions/StagedUploadService.php b/src/Submissions/StagedUploadService.php index f28ed2e..fa5c50e 100644 --- a/src/Submissions/StagedUploadService.php +++ b/src/Submissions/StagedUploadService.php @@ -7,6 +7,7 @@ use EvanSchleret\FormForge\Exceptions\FormForgeException; use EvanSchleret\FormForge\Models\StagedUpload; use EvanSchleret\FormForge\Support\ModelClassResolver; +use EvanSchleret\FormForge\Support\Antivirus\FileScanner; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\UploadedFile; use Illuminate\Support\Carbon; @@ -15,6 +16,11 @@ class StagedUploadService { + public function __construct( + private readonly FileScanner $scanner, + ) { + } + public function cleanupExpired(bool $dryRun = false, bool $deleteFiles = true, int $chunk = 500): array { $chunk = max(1, $chunk); @@ -73,6 +79,7 @@ public function stage(array $form, array $field, UploadedFile $file, ?Model $upl { $this->assertFileField($field); $this->validateUploadedFile($file, $field); + $this->scanFile($file); $disk = trim((string) config('formforge.uploads.temporary_disk', config('filesystems.default'))); $directory = $this->buildTemporaryDirectory( @@ -201,6 +208,13 @@ private function assertFileField(array $field): void } } + private function scanFile(UploadedFile $file): void + { + if ((bool) config('formforge.uploads.antivirus.enabled', false)) { + $this->scanner->scan($file); + } + } + private function validateUploadedFile(UploadedFile $file, array $field): void { $maxSize = isset($field['max_size']) ? (int) $field['max_size'] : null; @@ -209,7 +223,7 @@ private function validateUploadedFile(UploadedFile $file, array $field): void $size = (int) ($file->getSize() ?? 0); if ($size > $maxSize) { - throw new FormForgeException("Staged upload exceeds max_size [{$maxSize}] bytes."); + throw new FormForgeException(trans('formforge::messages.staged_upload_max_size', ['max' => $maxSize])); } } diff --git a/src/Submissions/SubmissionValidator.php b/src/Submissions/SubmissionValidator.php index d83c343..28b043f 100644 --- a/src/Submissions/SubmissionValidator.php +++ b/src/Submissions/SubmissionValidator.php @@ -849,7 +849,7 @@ private function compileFileRules(array &$rules, string $name, array $field, arr } $baseRules = $this->sanitizeMetadataBaseRules($baseRules); - $this->compileMetadataFileRules($rules, $name, $baseRules, $multiple); + $this->compileMetadataFileRules($rules, $name, $field, $baseRules, $multiple); } private function compileManagedFileRules(array &$rules, string $name, array $field, array $baseRules, bool $multiple): void @@ -869,6 +869,20 @@ private function compileManagedFileRules(array &$rules, string $name, array $fie $rules[$name] = $baseRules; $rules[$name . '.*'] = array_values(array_unique(['file', ...$acceptRules, ...$sizeRules])); + if (isset($field['max_total_size']) && (int) $field['max_total_size'] > 0) { + $rules[$name][] = function (string $attribute, mixed $value, Closure $fail) use ($field): void { + if (! is_array($value)) { + return; + } + + $total = array_reduce($value, static fn (int $sum, mixed $file): int => $sum + (int) ($file?->getSize() ?? 0), 0); + + if ($total > (int) $field['max_total_size']) { + $fail(trans('formforge::messages.upload_max_total_size')); + } + }; + } + return; } @@ -879,7 +893,7 @@ private function compileManagedFileRules(array &$rules, string $name, array $fie $rules[$name] = array_values(array_unique([...$baseRules, ...$acceptRules, ...$sizeRules])); } - private function compileMetadataFileRules(array &$rules, string $name, array $baseRules, bool $multiple): void + private function compileMetadataFileRules(array &$rules, string $name, array $field, array $baseRules, bool $multiple): void { if (! in_array('array', $baseRules, true)) { $baseRules[] = 'array'; @@ -888,6 +902,10 @@ private function compileMetadataFileRules(array &$rules, string $name, array $ba $rules[$name] = $baseRules; if ($multiple) { + if (isset($field['max_files'])) { + $baseRules[] = 'max:' . (int) $field['max_files']; + } + $rules[$name . '.*'] = ['exclude_without:' . $name, 'array']; $rules[$name . '.*.upload_token'] = ['exclude_without:' . $name, 'nullable', 'string']; $rules[$name . '.*.path'] = ['exclude_without:' . $name, 'required_without:' . $name . '.*.upload_token', 'string']; @@ -896,10 +914,28 @@ private function compileMetadataFileRules(array &$rules, string $name, array $ba $rules[$name . '.*.stored_name'] = ['exclude_without:' . $name, 'nullable', 'string']; $rules[$name . '.*.mime_type'] = ['exclude_without:' . $name, 'nullable', 'string']; $rules[$name . '.*.extension'] = ['exclude_without:' . $name, 'nullable', 'string']; - $rules[$name . '.*.size'] = ['exclude_without:' . $name, 'nullable', 'integer', 'min:0']; + $sizeRules = ['exclude_without:' . $name, 'nullable', 'integer', 'min:0']; + if (isset($field['max_size']) && (int) $field['max_size'] > 0) { + $sizeRules[] = 'max:' . (int) $field['max_size']; + } + $rules[$name . '.*.size'] = $sizeRules; $rules[$name . '.*.checksum'] = ['exclude_without:' . $name, 'nullable', 'string']; $rules[$name . '.*.metadata'] = ['exclude_without:' . $name, 'nullable', 'array']; + if (isset($field['max_total_size']) && (int) $field['max_total_size'] > 0) { + $rules[$name][] = function (string $attribute, mixed $value, Closure $fail) use ($field): void { + if (! is_array($value)) { + return; + } + + $total = array_reduce($value, static fn (int $sum, mixed $item): int => $sum + (int) (is_array($item) ? ($item['size'] ?? 0) : 0), 0); + + if ($total > (int) $field['max_total_size']) { + $fail(trans('formforge::messages.upload_max_total_size')); + } + }; + } + return; } @@ -910,7 +946,11 @@ private function compileMetadataFileRules(array &$rules, string $name, array $ba $rules[$name . '.stored_name'] = ['exclude_without:' . $name, 'nullable', 'string']; $rules[$name . '.mime_type'] = ['exclude_without:' . $name, 'nullable', 'string']; $rules[$name . '.extension'] = ['exclude_without:' . $name, 'nullable', 'string']; - $rules[$name . '.size'] = ['exclude_without:' . $name, 'nullable', 'integer', 'min:0']; + $sizeRules = ['exclude_without:' . $name, 'nullable', 'integer', 'min:0']; + if (isset($field['max_size']) && (int) $field['max_size'] > 0) { + $sizeRules[] = 'max:' . (int) $field['max_size']; + } + $rules[$name . '.size'] = $sizeRules; $rules[$name . '.checksum'] = ['exclude_without:' . $name, 'nullable', 'string']; $rules[$name . '.metadata'] = ['exclude_without:' . $name, 'nullable', 'array']; } diff --git a/src/Submissions/UploadManager.php b/src/Submissions/UploadManager.php index f3574a4..34e155d 100644 --- a/src/Submissions/UploadManager.php +++ b/src/Submissions/UploadManager.php @@ -7,6 +7,7 @@ use EvanSchleret\FormForge\Exceptions\FormForgeException; use EvanSchleret\FormForge\Exceptions\UnsupportedUploadModeException; use EvanSchleret\FormForge\Models\StagedUpload; +use EvanSchleret\FormForge\Support\Antivirus\FileScanner; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\UploadedFile; use Illuminate\Support\Arr; @@ -17,6 +18,7 @@ class UploadManager { public function __construct( private readonly StagedUploadService $stagedUploads, + private readonly FileScanner $scanner, ) { } @@ -79,6 +81,8 @@ private function processDirect(array $field, mixed $value): array $metadata[] = $this->normalizeExistingMetadata($field, $item); } + $this->validateMetadataLimits($field, $metadata); + $multiple = (bool) ($field['multiple'] ?? false); return [ @@ -185,6 +189,10 @@ private function storeUploadedFile(array $form, array $field, UploadedFile $file $extension = $file->getClientOriginalExtension() ?: $file->extension() ?: ''; $storedName = $this->storedFilename($originalName, $extension); + if ((bool) config('formforge.uploads.antivirus.enabled', false)) { + $this->scanner->scan($file); + } + $options = []; if ($visibility !== null) { @@ -233,9 +241,9 @@ private function normalizeExistingMetadata(array $field, array $item): array $storedName = (string) Arr::get($item, 'stored_name', basename($path)); $originalName = (string) Arr::get($item, 'original_name', $storedName); - $mimeType = (string) Arr::get($item, 'mime_type', Storage::disk($disk)->mimeType($path) ?: 'application/octet-stream'); + $mimeType = (string) (Storage::disk($disk)->mimeType($path) ?: Arr::get($item, 'mime_type', 'application/octet-stream')); $extension = (string) Arr::get($item, 'extension', pathinfo($path, PATHINFO_EXTENSION)); - $size = (int) Arr::get($item, 'size', Storage::disk($disk)->size($path)); + $size = (int) Storage::disk($disk)->size($path); $checksum = (string) Arr::get($item, 'checksum', $this->safeChecksum($disk, $path)); $metadata = Arr::get($item, 'metadata', []); @@ -258,6 +266,68 @@ private function normalizeExistingMetadata(array $field, array $item): array ]; } + private function validateMetadataLimits(array $field, array $metadata): void + { + $maxFiles = isset($field['max_files']) ? (int) $field['max_files'] : null; + + if ($maxFiles !== null && $maxFiles > 0 && count($metadata) > $maxFiles) { + throw new FormForgeException(trans('formforge::messages.upload_max_files')); + } + + $maxSize = isset($field['max_size']) ? (int) $field['max_size'] : null; + $maxTotalSize = isset($field['max_total_size']) ? (int) $field['max_total_size'] : null; + $totalSize = 0; + + foreach ($metadata as $item) { + $size = (int) ($item['size'] ?? 0); + $totalSize += $size; + + if ($maxSize !== null && $maxSize > 0 && $size > $maxSize) { + throw new FormForgeException(trans('formforge::messages.staged_upload_max_size', ['max' => $maxSize])); + } + + if (! $this->metadataMatchesAccept($item, $field['accept'] ?? [])) { + throw new FormForgeException(trans('formforge::messages.staged_upload_accepted_types_mismatch')); + } + } + + if ($maxTotalSize !== null && $maxTotalSize > 0 && $totalSize > $maxTotalSize) { + throw new FormForgeException(trans('formforge::messages.upload_max_total_size')); + } + } + + private function metadataMatchesAccept(array $metadata, mixed $accept): bool + { + if (! is_array($accept) || $accept === []) { + return true; + } + + $extension = strtolower(ltrim((string) ($metadata['extension'] ?? ''), '.')); + $mimeType = strtolower((string) ($metadata['mime_type'] ?? '')); + + foreach ($accept as $rawRule) { + $rule = strtolower(trim((string) $rawRule)); + + if ($rule === '' || $rule === '*' || ($rule === 'image/*' && str_starts_with($mimeType, 'image/'))) { + return true; + } + + if (str_starts_with($rule, '.') && $extension === ltrim($rule, '.')) { + return true; + } + + if (str_contains($rule, '/') && $mimeType === $rule) { + return true; + } + + if (! str_contains($rule, '/') && $extension === $rule) { + return true; + } + } + + return false; + } + private function commitStagedFile(array $form, array $field, array $item, ?Model $submittedBy = null): array { $stagedUpload = null; diff --git a/src/Support/Antivirus/ClamAvRestScanner.php b/src/Support/Antivirus/ClamAvRestScanner.php new file mode 100644 index 0000000..5b04f34 --- /dev/null +++ b/src/Support/Antivirus/ClamAvRestScanner.php @@ -0,0 +1,66 @@ +getRealPath(); + + if (! is_string($path) || $path === '') { + throw new FormForgeException(trans('formforge::messages.upload_antivirus_scan_failed')); + } + + $request = Http::timeout(max(1, (int) config('formforge.uploads.antivirus.timeout', 30))); + $username = trim((string) config('formforge.uploads.antivirus.username', '')); + $password = (string) config('formforge.uploads.antivirus.password', ''); + + if ($username !== '') { + $request = $request->withBasicAuth($username, $password); + } + + $stream = null; + + try { + $stream = fopen($path, 'rb'); + + if ($stream === false) { + throw new FormForgeException(trans('formforge::messages.upload_antivirus_scan_failed')); + } + + $response = $request + ->attach('file', $stream, $file->getClientOriginalName()) + ->post($endpoint); + + } catch (FormForgeException $exception) { + throw $exception; + } catch (\Throwable $exception) { + throw new FormForgeException(trans('formforge::messages.upload_antivirus_scan_failed'), previous: $exception); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + } + + if ($response->status() === 406) { + throw new FormForgeException(trans('formforge::messages.upload_antivirus_infected')); + } + + if (! $response->successful()) { + throw new FormForgeException(trans('formforge::messages.upload_antivirus_scan_failed')); + } + } +} diff --git a/src/Support/Antivirus/FileScanner.php b/src/Support/Antivirus/FileScanner.php new file mode 100644 index 0000000..43dbada --- /dev/null +++ b/src/Support/Antivirus/FileScanner.php @@ -0,0 +1,12 @@ +assertExists($payloadFile['path']); }); +it('enforces the combined file size limit in managed mode', function (): void { + Storage::fake('local'); + + config()->set('formforge.uploads.mode', 'managed'); + config()->set('formforge.uploads.disk', 'local'); + + $key = 'upload_total_' . Str::lower(Str::random(8)); + + Form::define($key) + ->version('1') + ->file('documents') + ->multiple() + ->maxTotalSize(100) + ->storageDisk('local'); + + expect(static fn () => Form::get($key, '1')->submit([ + 'documents' => [ + UploadedFile::fake()->create('one.txt', 64, 'text/plain'), + UploadedFile::fake()->create('two.txt', 64, 'text/plain'), + ], + ]))->toThrow(\Illuminate\Validation\ValidationException::class); +}); + +it('scans managed uploads with the configured ClamAV REST endpoint', function (): void { + Storage::fake('local'); + Http::fake(['https://clamav.test/v2/scan' => Http::response([['Status' => 'OK']], 200)]); + + config()->set('formforge.uploads.mode', 'managed'); + config()->set('formforge.uploads.disk', 'local'); + config()->set('formforge.uploads.antivirus.enabled', true); + config()->set('formforge.uploads.antivirus.endpoint', 'https://clamav.test/v2/scan'); + + $key = 'upload_scan_' . Str::lower(Str::random(8)); + + Form::define($key) + ->version('1') + ->file('document') + ->storageDisk('local'); + + Form::get($key, '1')->submit([ + 'document' => UploadedFile::fake()->create('document.txt', 1, 'text/plain'), + ]); + + Http::assertSent(fn ($request): bool => $request->url() === 'https://clamav.test/v2/scan'); +}); + it('accepts direct upload references and persists file metadata', function (): void { Storage::fake('local');