From c53a112abda22147addc94ddc649d4007b111190 Mon Sep 17 00:00:00 2001 From: Stefan Hagspiel Date: Tue, 21 Oct 2025 10:20:48 +0200 Subject: [PATCH 1/4] introduce upload field reference and server-side MIME type validation --- config/install/translations/admin.csv | 2 +- src/DependencyInjection/Configuration.php | 16 + .../Adapter/DropZoneAdapter.php | 39 +- .../Adapter/FineUploadAdapter.php | 32 +- src/Exception/UploadErrorException.php | 18 + src/Form/Type/DynamicMultiFileType.php | 24 +- src/Stream/FileStream.php | 541 ++++++++++++++---- src/Stream/FileStreamInterface.php | 2 +- 8 files changed, 538 insertions(+), 136 deletions(-) create mode 100644 src/Exception/UploadErrorException.php diff --git a/config/install/translations/admin.csv b/config/install/translations/admin.csv index 540bb039..d594e886 100644 --- a/config/install/translations/admin.csv +++ b/config/install/translations/admin.csv @@ -95,7 +95,7 @@ "form_builder_type_field.service_name","Service Name","Service Name" "form_builder_type_field.placeholder_desc","This option determines whether or not a special 'empty' option (e.g. 'Choose an option') will appear at the top of a select field. This option only applies if the multiple option is set to false. Leave empty for no default option.","Diese Option legt fest, ob eine spezielle 'leere' Option (z. B. 'Wählen Sie eine Option') am oberen Rand eines Auswahlfeldes erscheinen soll. Diese Option gilt nur, wenn die Option 'Mehrfach' nicht aktiv ist. Leer lassen für keine Standardoption." "form_builder_type_field.allowed_extensions","Allowed Extensions","Erlaubte Dateitypen" -"form_builder_type_field.allowed_extensions_desc","Add some extensions and confirm with enter.","Fügen Sie erlaubte Dateierweiterungen hinzu und bestätigen Sie mit der Enter-Taste." +"form_builder_type_field.allowed_extensions_desc","Add some extensions and confirm with Enter. If server-side MIME type validation is enabled, you can also specify full MIME types (e.g. application/pdf).","Fügen Sie erlaubte Dateierweiterungen hinzu und bestätigen Sie mit der Enter-Taste. Wenn die serverseitige MIME-Type-Validierung aktiviert ist, können Sie auch vollständige MIME-Typen angeben (z. B. application/pdf)." "form_builder_type_field.item_limit","Item Limit","Datei-Limit" "form_builder_type_field.item_limit_desc","The maximum number of files that can be uploaded. 0 = unlimited.","Die maximale Anzahl an Dateien welche hochgeladen werden darf. 0 = Kein Limit." "form_builder_type_field.max_file_size","Max File Size","Max. Dateigröße" diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index f5a5404c..18dd0b16 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -39,6 +39,7 @@ public function getConfigTreeBuilder() $rootNode->append($this->createPersistenceNode()); $rootNode->append($this->buildFlagsNode()); $rootNode->append($this->buildSpamProductionNode()); + $rootNode->append($this->buildSecurityNode()); $rootNode->append($this->buildAreaNode()); $rootNode->append($this->buildFormConfigurationNode()); $rootNode->append($this->buildAdminConfigurationNode()); @@ -608,6 +609,21 @@ private function buildAreaNode(): NodeDefinition return $rootNode; } + private function buildSecurityNode(): NodeDefinition + { + $builder = new TreeBuilder('security'); + + $rootNode = $builder->getRootNode(); + $rootNode + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enable_upload_field_reference')->defaultFalse()->end() + ->booleanNode('enable_upload_server_mime_type_validation')->defaultFalse()->end() + ->end(); + + return $rootNode; + } + private function buildSpamProductionNode(): NodeDefinition { $builder = new TreeBuilder('spam_protection'); diff --git a/src/DynamicMultiFile/Adapter/DropZoneAdapter.php b/src/DynamicMultiFile/Adapter/DropZoneAdapter.php index 11188dfa..146b6996 100644 --- a/src/DynamicMultiFile/Adapter/DropZoneAdapter.php +++ b/src/DynamicMultiFile/Adapter/DropZoneAdapter.php @@ -41,14 +41,15 @@ public function getJsHandler(): string public function onUpload(Request $request): Response { $result = $this->fileStream->handleUpload([ - 'binary' => 'dmfData', - 'uuid' => 'uuid', - 'chunkIndex' => 'dzchunkindex', - 'totalChunkCount' => 'dztotalchunkcount', - 'totalFileSize' => 'dztotalfilesize', + 'binary' => 'dmfData', + 'uuid' => 'uuid', + 'fieldReferenceKey' => 'fieldReference', + 'chunkIndex' => 'dzchunkindex', + 'totalChunkCount' => 'dztotalchunkcount', + 'totalFileSize' => 'dztotalfilesize', ]); - return new JsonResponse($result); + return new JsonResponse($result, $result['statusCode'] ?? 200); } public function onDone(Request $request): Response @@ -62,10 +63,30 @@ public function onDone(Request $request): Response public function onDelete(Request $request): Response { $identifier = $request->attributes->get('identifier'); - $checkChunkFolder = $request->request->get('uploadStatus') === 'canceled'; - $result = $this->fileStream->handleDelete($identifier, $checkChunkFolder); + $uploadStatus = null; + $checkChunkFolder = false; + $fieldReference = null; - return new JsonResponse($result); + if ($request->request->has('uploadStatus')) { + $uploadStatus = $request->request->get('uploadStatus'); + $checkChunkFolder = $uploadStatus === 'canceled'; + } + + if ($request->request->has('fieldReference')) { + $fieldReference = $request->request->get('fieldReference'); + } + + try { + $body = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); + $checkChunkFolder = ($body['uploadStatus'] ?? $uploadStatus) === 'canceled'; + $fieldReference = $body['fieldReference'] ?? $fieldReference; + } catch (\Throwable) { + // fail silently + } + + $result = $this->fileStream->handleDelete($identifier, $checkChunkFolder, $fieldReference); + + return new JsonResponse($result, $result['statusCode'] ?? 200); } } diff --git a/src/DynamicMultiFile/Adapter/FineUploadAdapter.php b/src/DynamicMultiFile/Adapter/FineUploadAdapter.php index 77dba02c..a1246790 100644 --- a/src/DynamicMultiFile/Adapter/FineUploadAdapter.php +++ b/src/DynamicMultiFile/Adapter/FineUploadAdapter.php @@ -44,14 +44,15 @@ public function onUpload(Request $request): Response if ($method === 'POST') { $result = $this->fileStream->handleUpload([ - 'binary' => 'qqfile', - 'uuid' => 'qquuid', - 'chunkIndex' => 'qqpartindex', - 'totalChunkCount' => 'qqtotalparts', - 'totalFileSize' => 'qqtotalfilesize', + 'binary' => 'qqfile', + 'uuid' => 'qquuid', + 'fieldReferenceKey' => 'fieldReference', + 'chunkIndex' => 'qqpartindex', + 'totalChunkCount' => 'qqtotalparts', + 'totalFileSize' => 'qqtotalfilesize', ], false); - return new JsonResponse($result); + return new JsonResponse($result, $result['statusCode'] ?? 200); } if ($method === 'DELETE') { @@ -64,14 +65,15 @@ public function onUpload(Request $request): Response public function onDone(Request $request): Response { $result = $this->fileStream->combineChunks([ - 'fileName' => $request->request->get('qqfilename'), - 'uuid' => 'qquuid', - 'chunkIndex' => 'qqpartindex', - 'totalChunkCount' => 'qqtotalparts', - 'totalFileSize' => 'qqtotalfilesize', + 'fileName' => $request->request->get('qqfilename'), + 'uuid' => 'qquuid', + 'fieldReferenceKey' => 'fieldReference', + 'chunkIndex' => 'qqpartindex', + 'totalChunkCount' => 'qqtotalparts', + 'totalFileSize' => 'qqtotalfilesize', ]); - return new JsonResponse($result, $result['statusCode']); + return new JsonResponse($result, $result['statusCode'] ?? 200); } public function onDelete(Request $request): Response @@ -80,8 +82,10 @@ public function onDelete(Request $request): Response ? $request->attributes->get('identifier') : $request->request->get('uuid'); - $result = $this->fileStream->handleDelete($identifier); + $fieldReference = $request->query->get('fieldReference'); - return new JsonResponse($result); + $result = $this->fileStream->handleDelete($identifier, false, $fieldReference); + + return new JsonResponse($result, $result['statusCode'] ?? 200); } } diff --git a/src/Exception/UploadErrorException.php b/src/Exception/UploadErrorException.php new file mode 100644 index 00000000..6c77883f --- /dev/null +++ b/src/Exception/UploadErrorException.php @@ -0,0 +1,18 @@ +addEventListener(FormEvents::POST_SET_DATA, [$this, 'handleDynamicMultiFileForm']); + } + + public function handleDynamicMultiFileForm(FormEvent $event): void + { + $adapterFormFieldName = 'adapter'; + $dmfAdapterName = $this->configuration->getConfig('dynamic_multi_file_adapter'); $dmfAdapter = $this->dynamicMultiFileAdapterRegistry->get($dmfAdapterName); $options['compound'] = true; + $options['auto_initialize'] = false; $options['label'] = empty($options['label']) ? false : $options['label']; $options['attr']['data-dynamic-multi-file-instance'] = 'true'; $options['attr']['data-js-handler'] = $dmfAdapter->getJsHandler(); - $adapterFormFieldName = 'adapter'; + $rootForm = $event->getForm()->getRoot(); + if ($rootForm->getData() instanceof FormData) { + $options['attr']['data-field-reference'] = sprintf( + '%s:%s', + $rootForm->getData()->getFormDefinition()->getId(), + $event->getForm()->getName() + ); + } $dmfForm = $this->formFactory->createNamedBuilder( $adapterFormFieldName, @@ -66,7 +84,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $this->dispatchFormTypeOptionsEvent($adapterFormFieldName, $dmfAdapter->getForm(), $options) ); - $dmfForm->add('data', HiddenType::class, []); + $dmfForm->add('data', HiddenType::class); $dmfForm->get('data')->addModelTransformer(new CallbackTransformer( function ($identifier) { return $identifier === null ? null : json_encode($identifier, JSON_THROW_ON_ERROR); @@ -76,7 +94,7 @@ function ($identifier) { } )); - $builder->add($dmfForm); + $event->getForm()->add($dmfForm->getForm()); } private function dispatchFormTypeOptionsEvent(string $name, string $type, array $options): array diff --git a/src/Stream/FileStream.php b/src/Stream/FileStream.php index a9e1edf5..2b8385e3 100644 --- a/src/Stream/FileStream.php +++ b/src/Stream/FileStream.php @@ -13,6 +13,11 @@ namespace FormBuilderBundle\Stream; +use FormBuilderBundle\Configuration\Configuration; +use FormBuilderBundle\Exception\UploadErrorException; +use FormBuilderBundle\Manager\FormDefinitionManager; +use FormBuilderBundle\Model\FormDefinitionInterface; +use FormBuilderBundle\Model\FormFieldDefinitionInterface; use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemOperator; use League\Flysystem\StorageAttributes; @@ -20,118 +25,132 @@ use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Mime\MimeTypeGuesserInterface; class FileStream implements FileStreamInterface { - public array $allowedExtensions = []; - public ?int $sizeLimit = null; + private const STORAGE_FILE = 'file'; + private const STORAGE_CHUNK = 'chunk'; public function __construct( + protected Configuration $configuration, protected RequestStack $requestStack, protected FilesystemOperator $formBuilderChunkStorage, - protected FilesystemOperator $formBuilderFilesStorage + protected FilesystemOperator $formBuilderFilesStorage, + protected MimeTypeGuesserInterface $mimeTypeGuesser, + protected FormDefinitionManager $formDefinitionManager ) { } public function handleUpload(array $options = [], bool $instantChunkCombining = true): array { $binaryIdentifier = $options['binary']; + $fieldReferenceKey = $options['fieldReferenceKey'] ?? null; $mainRequest = $this->requestStack->getMainRequest(); if (!$mainRequest instanceof Request) { return [ - 'success' => false, - 'error' => 'No request given' - ]; - } - - if ($this->toBytes(ini_get('post_max_size')) < $this->sizeLimit || $this->toBytes(ini_get('upload_max_filesize')) < $this->sizeLimit) { - $neededRequestSize = max(1, $this->sizeLimit / 1024 / 1024) . 'M'; - - return [ - 'success' => false, - 'error' => sprintf('Server error. Increase post_max_size and upload_max_filesize to %s', $neededRequestSize) + 'success' => false, + 'statusCode' => 400, + 'error' => 'No request given' ]; } $type = $mainRequest->headers->get('Content-Type'); + $fieldReference = $fieldReferenceKey !== null ? $mainRequest->request->get($fieldReferenceKey) : null; if (empty($type)) { return [ - 'success' => false, - 'error' => 'No files were uploaded.' + 'success' => false, + 'statusCode' => 400, + 'error' => 'No files were uploaded.' ]; } if (!str_starts_with(strtolower($type), 'multipart/')) { return [ - 'success' => false, - 'error' => 'Server error. Not a multipart request. Please set forceMultipart to default value (true).' + 'success' => false, + 'statusCode' => 400, + 'error' => 'Server error. Not a multipart request. Please set forceMultipart to default value (true).' ]; } - /** @var UploadedFile $file */ $file = $mainRequest->files->get($binaryIdentifier); - $size = $file->getSize(); - $fileName = $file->getClientOriginalName(); - $fileExtension = $file->getClientOriginalExtension(); - - if ($mainRequest->request->has($options['totalFileSize'])) { - $size = $mainRequest->request->get($options['totalFileSize']); + if (!$file instanceof UploadedFile) { + return [ + 'success' => false, + 'statusCode' => 400, + 'fileName' => null, + 'error' => 'no file' + ]; } + $fileName = $file->getClientOriginalName(); + $serverFileSafeName = $this->getSafeFileName($fileName, true); $fileSafeName = $this->getSafeFileName($fileName); - // check file error if ($file->getError() !== UPLOAD_ERR_OK) { return [ - 'success' => false, - 'fileName' => $fileSafeName, - 'error' => sprintf('Upload Error: %s', $file->getErrorMessage()) + 'success' => false, + 'statusCode' => 400, + 'fileName' => $fileSafeName, + 'error' => 'upload error' ]; } - // Validate name - if ($fileSafeName === '') { - return [ - 'success' => false, - 'fileName' => $fileSafeName, - 'error' => 'File name empty.' - ]; + if ($this->fieldReferenceEnabled() === true) { + try { + $this->assertFieldReference($fieldReference); + } catch (UploadErrorException $e) { + return [ + 'success' => false, + 'statusCode' => 400, + 'error' => $e->getMessage() + ]; + } } - // Validate file size - if ($size === 0 || $size === '0') { + $uploadRestrictions = $this->getUploadRestrictions($fieldReference); + + $sizeLimit = $uploadRestrictions['sizeLimit']; + $allowedMimeTypes = $uploadRestrictions['allowedMimeTypes']; + + try { + $uuid = $this->determinateUuid($mainRequest, $options['uuid'] ?? null); + } catch (UploadErrorException $e) { return [ - 'success' => false, - 'fileName' => $fileSafeName, - 'error' => 'File is empty.' + 'success' => false, + 'statusCode' => 400, + 'fileName' => $fileSafeName, + 'error' => $e->getMessage() ]; } - if (!is_null($this->sizeLimit) && $size > $this->sizeLimit) { + if ($fileSafeName === '') { return [ - 'success' => false, - 'fileName' => $fileSafeName, - 'error' => 'File is too large.', - 'preventRetry' => true + 'success' => false, + 'statusCode' => 400, + 'fileName' => $fileSafeName, + 'error' => 'File name empty.' ]; } - if (count($this->allowedExtensions) > 0 && - !in_array(strtolower($fileExtension), array_map('strtolower', $this->allowedExtensions), true)) { + try { + $this->validateUploadedFileSize($file, $sizeLimit, $options, $mainRequest); + } catch (UploadErrorException $e) { return [ - 'success' => false, - 'fileName' => $fileSafeName, - 'error' => sprintf('File has an invalid extension, it should be one of %s.', implode(', ', $this->allowedExtensions)) + 'success' => false, + 'statusCode' => $e->getCode(), + 'fileName' => $fileSafeName, + 'error' => $e->getMessage(), + 'preventRetry' => $e->getCode() !== 400 ]; } $totalParts = $mainRequest->request->has($options['totalChunkCount']) ? (int) $mainRequest->request->get($options['totalChunkCount']) : 1; - $uuid = $mainRequest->request->get($options['uuid']); if ($totalParts > 1) { // chunked upload @@ -141,10 +160,11 @@ public function handleUpload(array $options = [], bool $instantChunkCombining = $this->formBuilderChunkStorage->write(sprintf('%s%s%s', $uuid, DIRECTORY_SEPARATOR, $partIndex), file_get_contents($file->getPathname())); } catch (FilesystemException $e) { return [ - 'success' => false, - 'fileName' => $fileSafeName, - 'error' => $e->getMessage(), - 'uuid' => $uuid + 'success' => false, + 'statusCode' => 400, + 'fileName' => $fileSafeName, + 'error' => $e->getMessage(), + 'uuid' => $uuid ]; } @@ -160,14 +180,26 @@ public function handleUpload(array $options = [], bool $instantChunkCombining = ]; } + try { + $this->validateUploadedFileMimeType($file, $allowedMimeTypes); + } catch (UploadErrorException $e) { + return [ + 'success' => false, + 'statusCode' => $e->getCode(), + 'fileName' => $fileSafeName, + 'error' => $e->getMessage(), + ]; + } + try { $this->formBuilderFilesStorage->write($uuid . '/' . $serverFileSafeName, file_get_contents($file->getPathname())); } catch (FilesystemException $e) { return [ - 'success' => false, - 'fileName' => $fileSafeName, - 'error' => $e->getMessage(), - 'uuid' => $uuid + 'success' => false, + 'statusCode' => 400, + 'fileName' => $fileSafeName, + 'error' => $e->getMessage(), + 'uuid' => $uuid ]; } @@ -185,103 +217,382 @@ public function combineChunks(array $options = []): array if (!$mainRequest instanceof Request) { return [ - 'statusCode' => 400, - 'success' => false, + 'success' => false, + 'statusCode' => 400, ]; } - $uuid = $mainRequest->request->get($options['uuid']); $fileSafeName = $this->getSafeFileName($options['fileName']); - $tmpStream = tmpfile(); - $chunkFiles = $this->formBuilderChunkStorage->listContents($uuid)->toArray(); - - usort($chunkFiles, static function (StorageAttributes $a, StorageAttributes $b) { - $pathInfoA = pathinfo($a->path()); - $pathInfoB = pathinfo($b->path()); + try { + $uuid = $this->determinateUuid($mainRequest, $options['uuid'] ?? null); + } catch (UploadErrorException $e) { + return [ + 'success' => false, + 'statusCode' => 400, + 'fileName' => $fileSafeName, + 'error' => $e->getMessage() + ]; + } - return $pathInfoA['filename'] <=> $pathInfoB['filename']; - }); + $fieldReferenceKey = $options['fieldReferenceKey'] ?? null; + $fieldReference = $fieldReferenceKey !== null ? $mainRequest->request->get($fieldReferenceKey) : null; - foreach ($chunkFiles as $chunkFile) { - $chunkPathResource = $this->formBuilderChunkStorage->readStream($chunkFile->path()); - stream_copy_to_stream($chunkPathResource, $tmpStream); - fclose($chunkPathResource); + if ($this->fieldReferenceEnabled() === true) { + try { + $this->assertFieldReference($fieldReference); + } catch (UploadErrorException) { + return [ + 'success' => false, + 'statusCode' => 400, + 'preventRetry' => true, + 'uuid' => $uuid, + 'fileName' => $fileSafeName, + ]; + } } try { + $tmpStream = tmpfile(); + $chunkFiles = $this->formBuilderChunkStorage->listContents($uuid)->toArray(); + + usort($chunkFiles, static function (StorageAttributes $a, StorageAttributes $b) { + $pathInfoA = pathinfo($a->path()); + $pathInfoB = pathinfo($b->path()); + + return $pathInfoA['filename'] <=> $pathInfoB['filename']; + }); + + foreach ($chunkFiles as $chunkFile) { + $chunkPathResource = $this->formBuilderChunkStorage->readStream($chunkFile->path()); + stream_copy_to_stream($chunkPathResource, $tmpStream); + fclose($chunkPathResource); + } + $this->formBuilderFilesStorage->writeStream($uuid . '/' . $fileSafeName, $tmpStream); - } catch (FilesystemException $exception) { + + // Success + fclose($tmpStream); + + } catch (\Throwable) { $chunkSuccess = false; } - // Success - fclose($tmpStream); - if ($chunkSuccess === false) { - try { - $this->formBuilderChunkStorage->deleteDirectory($uuid); - } catch (FilesystemException $exception) { - // fail silently - } - try { - $this->formBuilderFilesStorage->deleteDirectory($uuid); - } catch (FilesystemException $exception) { - // fail silently - } + $this->removeUploadDirectories($uuid); return [ - 'statusCode' => 413, 'success' => false, + 'statusCode' => 400, 'preventRetry' => true, 'uuid' => $uuid, 'fileName' => $fileSafeName, ]; } + $this->removeUploadDirectories($uuid, [self::STORAGE_CHUNK]); + + $uploadRestrictions = $this->getUploadRestrictions($fieldReference); + $sizeLimit = $uploadRestrictions['sizeLimit']; + $allowedMimeTypes = $uploadRestrictions['allowedMimeTypes']; + + $filePath = sprintf('%s/%s', $uuid, $fileSafeName); + try { - $this->formBuilderChunkStorage->deleteDirectory($uuid); - } catch (FilesystemException $exception) { - // fail silently - } + $this->validateStorageFileSize($filePath, $sizeLimit); + } catch (UploadErrorException $e) { - $fileSize = $this->formBuilderFilesStorage->fileSize($uuid . '/' . $fileSafeName); + $this->removeUploadDirectories($uuid); - if (!is_null($this->sizeLimit) && $fileSize > $this->sizeLimit) { return [ - 'statusCode' => 413, 'success' => false, - 'preventRetry' => true, - 'uuid' => $uuid, + 'statusCode' => $e->getCode(), 'fileName' => $fileSafeName, + 'error' => $e->getMessage(), + 'preventRetry' => $e->getCode() !== 400 + ]; + } + + try { + $this->validateStorageFileMimeType($filePath, $allowedMimeTypes); + } catch (UploadErrorException $e) { + + $this->removeUploadDirectories($uuid); + + return [ + 'success' => false, + 'statusCode' => $e->getCode(), + 'fileName' => $fileSafeName, + 'error' => $e->getMessage(), ]; } return [ - 'statusCode' => 200, - 'success' => true, - 'uuid' => $uuid, - 'fileName' => $fileSafeName, + 'success' => true, + 'uuid' => $uuid, + 'fileName' => $fileSafeName, ]; } - public function handleDelete(string $identifier, bool $checkChunkFolder = false): array + public function handleDelete(string $identifier, bool $checkChunkFolder = false, ?string $fieldReference = null): array { - if ($checkChunkFolder === true && $this->formBuilderChunkStorage->directoryExists($identifier)) { - $this->formBuilderChunkStorage->deleteDirectory($identifier); + if ($this->fieldReferenceEnabled() === true) { + try { + $this->assertFieldReference($fieldReference); + } catch (UploadErrorException $e) { + return [ + 'success' => false, + 'statusCode' => 400, + 'message' => $e->getMessage(), + ]; + } } - if ($this->formBuilderFilesStorage->directoryExists($identifier)) { - $this->formBuilderFilesStorage->deleteDirectory($identifier); + $storages = [self::STORAGE_FILE]; + + if ($checkChunkFolder === true) { + $storages[] = self::STORAGE_CHUNK; } + $this->removeUploadDirectories($identifier, $storages); + return [ 'success' => true, 'uuid' => $identifier ]; } + /** + * @throws UploadErrorException + */ + protected function assertFieldReference(?string $fieldReference): void + { + if ($this->fieldReferenceEnabled() === false) { + return; + } + + $formField = $this->getFieldByReference($fieldReference); + + if ($formField === null) { + throw new UploadErrorException('Field reference is invalid'); + } + } + + protected function getUploadRestrictions(?string $fieldReference): array + { + $restrictions = [ + 'sizeLimit' => null, + 'allowedMimeTypes' => [], + ]; + + if ($this->fieldReferenceEnabled() === false) { + return $restrictions; + } + + $formField = $this->getFieldByReference($fieldReference); + if (!$formField instanceof FormFieldDefinitionInterface) { + return $restrictions; + } + + $formFieldOptions = $formField->getOptions(); + + $sizeLimit = $formFieldOptions['max_file_size'] ?? null; + $allowedMimeTypes = $formFieldOptions['allowed_extensions'] ?? []; + + if (is_numeric($sizeLimit)) { + $sizeLimit = (int) ($sizeLimit * 1024 * 1024); + } + + $restrictions['sizeLimit'] = $sizeLimit; + $restrictions['allowedMimeTypes'] = count($allowedMimeTypes) > 0 ? array_map('strtolower', $allowedMimeTypes) : []; + + return $restrictions; + } + + protected function getFieldByReference(?string $fieldReference): ?FormFieldDefinitionInterface + { + if ($fieldReference === null) { + return null; + } + + try { + [$formId, $fieldName] = explode(':', $fieldReference); + } catch (\Throwable) { + return null; + } + + $form = $this->formDefinitionManager->getById((int) $formId); + + if (!$form instanceof FormDefinitionInterface) { + return null; + } + + $field = $form->getField($fieldName); + + if (!$field instanceof FormFieldDefinitionInterface) { + return null; + } + + if ($field->getType() !== 'dynamic_multi_file') { + return null; + } + + return $field; + } + + /** + * @throws UploadErrorException + */ + protected function validateUploadedFileMimeType(UploadedFile $file, array $allowedMimeTypes): void + { + $fileMimeType = null; + + try { + $fileMimeType = $this->mimeTypeGuesser->guessMimeType($file->getPathname()); + } catch (\Throwable) { + // fail silently + } + + $this->validateMimeType($fileMimeType, $allowedMimeTypes); + } + + /** + * @throws UploadErrorException + */ + protected function validateUploadedFileSize(UploadedFile $file, ?int $allowedFilesize, array $options, Request $request): void + { + $filesize = $file->getSize(); + + $totalFileSize = $options['totalFileSize'] ?? null; + if ($totalFileSize !== null && $request->request->has($totalFileSize)) { + $filesize = $request->request->get($totalFileSize); + } + + $filesize = is_numeric($filesize) ? (int) $filesize : null; + + $this->validateSize($filesize, $allowedFilesize); + } + + /** + * @throws UploadErrorException + */ + protected function validateStorageFileMimeType(string $path, array $allowedMimeTypes): void + { + $fileMimeType = null; + + try { + $fileMimeType = $this->formBuilderFilesStorage->mimeType($path); + } catch (\Throwable) { + // fail silently + } + + $this->validateMimeType($fileMimeType, $allowedMimeTypes); + } + + /** + * @throws UploadErrorException + */ + protected function validateStorageFileSize(string $path, ?int $allowedFilesize): void + { + $fileSize = null; + + try { + $fileSize = $this->formBuilderFilesStorage->fileSize($path); + } catch (\Throwable) { + // fail silently + } + + $this->validateSize($fileSize, $allowedFilesize); + } + + /** + * @throws UploadErrorException + */ + protected function validateMimeType(?string $fileMimeType, array $allowedMimeTypes): void + { + if ($this->serverMimeTypeValidationEnabled() === false) { + return; + } + + if (count($allowedMimeTypes) === 0) { + return; + } + + if ($fileMimeType === null) { + return; + } + + if (!in_array($fileMimeType, $allowedMimeTypes, true)) { + throw new UploadErrorException( + sprintf( + 'File has an invalid mime type, it should be one of %s.', + implode(', ', $allowedMimeTypes) + ), + 400 + ); + } + } + + /** + * @throws UploadErrorException + */ + protected function validateSize(?int $filesize, ?int $allowedFilesize): void + { + if ($allowedFilesize === null) { + return; + } + + if ($filesize === 0) { + throw new UploadErrorException('File is empty.', 400); + } + + if ($filesize > $allowedFilesize) { + throw new UploadErrorException('File is too large.', 413); + } + } + + /** + * @throws UploadErrorException + */ + protected function determinateUuid(Request $request, ?string $uuidIdentifier): string + { + if ($uuidIdentifier === null) { + throw new UploadErrorException('Missing uuid identifier', 400); + } + + $uuid = $request->request->get($uuidIdentifier); + + if ($uuid === null || $uuid === '') { + throw new UploadErrorException('Missing uuid', 400); + } + + return preg_replace('/[^A-Za-z0-9_-]/', '-', $uuid); + } + + protected function removeUploadDirectories(string $location, array $storages = [self::STORAGE_FILE, self::STORAGE_CHUNK]): void + { + try { + + if ( + in_array(self::STORAGE_CHUNK, $storages, true) && + $this->formBuilderChunkStorage->directoryExists($location) + ) { + $this->formBuilderChunkStorage->deleteDirectory($location); + } + + if ( + in_array(self::STORAGE_FILE, $storages, true) && + $this->formBuilderFilesStorage->directoryExists($location) + ) { + $this->formBuilderFilesStorage->deleteDirectory($location); + } + + } catch (FilesystemException) { + // fail silently + } + } + protected function toBytes(mixed $sizeStr): int|string { $val = trim($sizeStr); @@ -307,4 +618,18 @@ protected function getSafeFileName(string $fileName, bool $strong = false): stri return preg_replace('/[^a-zA-Z0-9]_+/', '', str_replace('.', '_', $fileName)); } + + protected function fieldReferenceEnabled(): bool + { + $security = $this->configuration->getConfig('security'); + + return $security['enable_upload_field_reference'] === true; + } + + protected function serverMimeTypeValidationEnabled(): bool + { + $security = $this->configuration->getConfig('security'); + + return $security['enable_upload_server_mime_type_validation'] === true; + } } diff --git a/src/Stream/FileStreamInterface.php b/src/Stream/FileStreamInterface.php index 1e1fbbd9..17a976b7 100644 --- a/src/Stream/FileStreamInterface.php +++ b/src/Stream/FileStreamInterface.php @@ -19,5 +19,5 @@ public function handleUpload(array $options = [], bool $instantChunkCombining = public function combineChunks(array $options = []): array; - public function handleDelete(string $identifier, bool $checkChunkFolder = false): array; + public function handleDelete(string $identifier, bool $checkChunkFolder = false, ?string $fieldReference = null): array; } From 9a8f3ae85328af9e9ef0d5d82816ba9223dfe174 Mon Sep 17 00:00:00 2001 From: Stefan Hagspiel Date: Tue, 21 Oct 2025 10:21:06 +0200 Subject: [PATCH 2/4] update UPGRADE.md and FileUpload documentation with security details for field reference and server-side MIME type validation --- UPGRADE.md | 3 +++ docs/80_FileUpload.md | 60 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 8dc02e7f..504f629d 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,8 @@ # Upgrade Notes +## 5.3.4 +- **[SECURITY]** Introduce upload field reference and server-side MIME type validation. Read more about upload security [here](./docs/80_FileUpload.md#security) + ## 5.3.3 - **[BUGFIX]** Sanitize form field values by removing template tags during output transformation diff --git a/docs/80_FileUpload.md b/docs/80_FileUpload.md index b5a9e831..19130f6d 100644 --- a/docs/80_FileUpload.md +++ b/docs/80_FileUpload.md @@ -1,6 +1,6 @@ # Dynamic Multi File -FormBuilder comes with a smart multi file upload type. +FormBuilder comes with a smart multi-file upload type. It allows you to use different adapters/libraries like FineUploader or DropZoneJs. ## Highlights @@ -8,22 +8,22 @@ It allows you to use different adapters/libraries like FineUploader or DropZoneJ - Large File Support: Process chunked files to allow large file uploads - Different adapters: Choose between different upload handler or create a custom one! - Stateless: no session is required to handle file uploads -- Different storage principals: Store data as pimcore assets (`/formdata` asset folder) and add download-link to mail **or** add them as native mail attachments +- Different storage principals: Store data as pimcore assets (`/formdata` asset folder) and add a download-link to mail **or** add them as native mail attachments - Stay clean: unsubmitted data / chunk data will be swiped via maintenance -- Prebuild Extensions: Use included jQuery extensions to set up your form in front end in no time! +- Prebuild Extensions: Use included jQuery extensions to set up your form in the front end in no time! ## Field Configuration There are some options in the (backend) field configuration: -| Name | Description -|------|------------| -| `Max File Size` | Max file size will be calculated in MB. Empty or zero means no limit | -| `Allowed Extensions` | Define allowed extensions, for example: `pdf, zip` (Format depends on active adapter) | -| `Item limit` | The maximum number of files that can be uploaded. Empty or zero means no limit | +| Name | Description | +|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Max File Size` | Max file size will be calculated in MB. Empty or zero means no limit | +| `Allowed Extensions` | Define allowed extensions, for example: `pdf, zip` (Format depends on active adapter) | +| `Item limit` | The maximum number of files that can be uploaded. Empty or zero means no limit | | `Send Files as Attachment` | All Files will be stored in your pimcore asset structure (/formdata) by default. If you check this option, the files will be attached to the mail instead of adding a download link | ## Setup -Per default, FineUploader will be used. If you want to change the dmf adapter, you need to define it: +Per default, DropZone will be used. If you want to change the dmf adapter, you need to define it: ```yaml form_builder: @@ -34,6 +34,48 @@ form_builder: By default, you don't need to implement more than the standard initialization, described in [FormBuilder Javascript Core Extension](./91_Javascript.md). The core extension will try to fetch the handler path, defined by `dynamicMultiFileHandlerOptions.defaultHandlerPath`. +## Security + +### Field Reference + +```yaml +form_builder: + security: + enable_upload_field_reference: true +``` + +The Field Reference feature ensures that every uploaded file is associated with an existing form field. +When enabled, the client must send a reference to the corresponding form field along with the file. +The server validates that the field exists and that the upload complies with the field’s configuration (e.g., allowed file types, maximum upload size). + +> [!CAUTION] +> This option is disabled by default to avoid breaking existing uploads. +> Enable it when you want to enforce field-level validation. + +### Server MIME Type Validation + +```yaml +form_builder: + security: + enable_upload_server_mime_type_validation: true +``` + +Server MIME Type Validation enforces that uploaded files match the allowed MIME types defined on the server. +When enabled, the server checks the actual file content rather than relying solely on file extensions provided by the client. +This ensures that only valid file types are accepted, preventing spoofed files or uploads that do not match the allowed formats. + +> [!CAUTION] +>Important: When MIME type validation is active, you must list the allowed MIME types in your form field configuration. +> Users can no longer just specify file extensions (like `pdf` or `jpg`); the server will validate the real MIME type of the uploaded file (for example, `application/pdf` or `image/jpeg`). +> If the MIME type of the file is not included in the server configuration, the upload will be rejected, even if the file extension appears valid. +> Always ensure the MIME types in your configuration match the types you expect to allow for uploads. + +> [!CAUTION] +> Note: This option is disabled by default to maintain backward compatibility. +> Enable it when you want the server to strictly validate MIME types for uploaded files. + +*** + ## Available Adapter - [DropZoneJs](./DynamicMultiFile/01_DropZoneJs.md) - [FineUploader](./DynamicMultiFile/02_FineUploader.md) From f71b5530213d6b345438e31aa6c68000fca7b1ab Mon Sep 17 00:00:00 2001 From: Stefan Hagspiel Date: Tue, 21 Oct 2025 10:27:55 +0200 Subject: [PATCH 3/4] simplify field reference validation logic in FileStream and improve options handling in DynamicMultiFileType --- src/Form/Type/DynamicMultiFileType.php | 2 + src/Stream/FileStream.php | 58 ++++++++++++-------------- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/src/Form/Type/DynamicMultiFileType.php b/src/Form/Type/DynamicMultiFileType.php index 666d36ef..9459217d 100644 --- a/src/Form/Type/DynamicMultiFileType.php +++ b/src/Form/Type/DynamicMultiFileType.php @@ -62,6 +62,8 @@ public function handleDynamicMultiFileForm(FormEvent $event): void $dmfAdapterName = $this->configuration->getConfig('dynamic_multi_file_adapter'); $dmfAdapter = $this->dynamicMultiFileAdapterRegistry->get($dmfAdapterName); + $options = $event->getForm()->getConfig()->getOptions(); + $options['compound'] = true; $options['auto_initialize'] = false; $options['label'] = empty($options['label']) ? false : $options['label']; diff --git a/src/Stream/FileStream.php b/src/Stream/FileStream.php index 2b8385e3..c77a6d55 100644 --- a/src/Stream/FileStream.php +++ b/src/Stream/FileStream.php @@ -101,16 +101,14 @@ public function handleUpload(array $options = [], bool $instantChunkCombining = ]; } - if ($this->fieldReferenceEnabled() === true) { - try { - $this->assertFieldReference($fieldReference); - } catch (UploadErrorException $e) { - return [ - 'success' => false, - 'statusCode' => 400, - 'error' => $e->getMessage() - ]; - } + try { + $this->assertFieldReference($fieldReference); + } catch (UploadErrorException $e) { + return [ + 'success' => false, + 'statusCode' => 400, + 'error' => $e->getMessage() + ]; } $uploadRestrictions = $this->getUploadRestrictions($fieldReference); @@ -238,18 +236,16 @@ public function combineChunks(array $options = []): array $fieldReferenceKey = $options['fieldReferenceKey'] ?? null; $fieldReference = $fieldReferenceKey !== null ? $mainRequest->request->get($fieldReferenceKey) : null; - if ($this->fieldReferenceEnabled() === true) { - try { - $this->assertFieldReference($fieldReference); - } catch (UploadErrorException) { - return [ - 'success' => false, - 'statusCode' => 400, - 'preventRetry' => true, - 'uuid' => $uuid, - 'fileName' => $fileSafeName, - ]; - } + try { + $this->assertFieldReference($fieldReference); + } catch (UploadErrorException) { + return [ + 'success' => false, + 'statusCode' => 400, + 'preventRetry' => true, + 'uuid' => $uuid, + 'fileName' => $fileSafeName, + ]; } try { @@ -337,16 +333,14 @@ public function combineChunks(array $options = []): array public function handleDelete(string $identifier, bool $checkChunkFolder = false, ?string $fieldReference = null): array { - if ($this->fieldReferenceEnabled() === true) { - try { - $this->assertFieldReference($fieldReference); - } catch (UploadErrorException $e) { - return [ - 'success' => false, - 'statusCode' => 400, - 'message' => $e->getMessage(), - ]; - } + try { + $this->assertFieldReference($fieldReference); + } catch (UploadErrorException $e) { + return [ + 'success' => false, + 'statusCode' => 400, + 'message' => $e->getMessage(), + ]; } $storages = [self::STORAGE_FILE]; From fcc62070406d1ff9bb6fdc7bca64f9b30422e294 Mon Sep 17 00:00:00 2001 From: Stefan Hagspiel Date: Tue, 21 Oct 2025 11:39:06 +0200 Subject: [PATCH 4/4] [TEST] update js scripts to v1.2.0 across templates and controller --- tests/_etc/config/app/controller/DefaultController.php | 4 ++-- tests/_etc/config/app/views/dynamic-multi-file.html.twig | 4 ++-- tests/_etc/config/app/views/javascript.html.twig | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/_etc/config/app/controller/DefaultController.php b/tests/_etc/config/app/controller/DefaultController.php index bcc6042e..fede9d1d 100644 --- a/tests/_etc/config/app/controller/DefaultController.php +++ b/tests/_etc/config/app/controller/DefaultController.php @@ -42,12 +42,12 @@ public function dynamicMultiFileAction(Request $request): Response if ($this->document->getKey() === 'drop-zone') { $options = [ - 'defaultHandlerPath' => 'https://rawcdn.githack.com/dachcom-digital/jquery-pimcore-formbuilder/v1.0.0/dist/dynamic-multi-file', + 'defaultHandlerPath' => 'https://rawcdn.githack.com/dachcom-digital/jquery-pimcore-formbuilder/v1.2.0/dist/dynamic-multi-file', 'libPath' => 'https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.9.2/min/dropzone.min.js' ]; } elseif ($this->document->getKey() === 'fine-uploader') { $options = [ - 'defaultHandlerPath' => 'https://rawcdn.githack.com/dachcom-digital/jquery-pimcore-formbuilder/v1.0.0/dist/dynamic-multi-file', + 'defaultHandlerPath' => 'https://rawcdn.githack.com/dachcom-digital/jquery-pimcore-formbuilder/v1.2.0/dist/dynamic-multi-file', 'libPath' => 'https://cdnjs.cloudflare.com/ajax/libs/file-uploader/5.16.2/jquery.fine-uploader/jquery.fine-uploader.min.js' ]; } diff --git a/tests/_etc/config/app/views/dynamic-multi-file.html.twig b/tests/_etc/config/app/views/dynamic-multi-file.html.twig index edefe779..59bf6c07 100644 --- a/tests/_etc/config/app/views/dynamic-multi-file.html.twig +++ b/tests/_etc/config/app/views/dynamic-multi-file.html.twig @@ -11,8 +11,8 @@ {% endblock %} - - + + - - - + + +