Skip to content

Commit f49bd58

Browse files
authored
Secure upload (#528)
introduce upload field reference and server-side MIME type validation
1 parent 1a31806 commit f49bd58

File tree

13 files changed

+593
-150
lines changed

13 files changed

+593
-150
lines changed

UPGRADE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Upgrade Notes
22

3+
## 5.3.4
4+
- **[SECURITY]** Introduce upload field reference and server-side MIME type validation. Read more about upload security [here](./docs/80_FileUpload.md#security)
5+
36
## 5.3.3
47
- **[BUGFIX]** Sanitize form field values by removing template tags during output transformation
58

config/install/translations/admin.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@
9595
"form_builder_type_field.service_name","Service Name","Service Name"
9696
"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."
9797
"form_builder_type_field.allowed_extensions","Allowed Extensions","Erlaubte Dateitypen"
98-
"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."
98+
"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)."
9999
"form_builder_type_field.item_limit","Item Limit","Datei-Limit"
100100
"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."
101101
"form_builder_type_field.max_file_size","Max File Size","Max. Dateigröße"

docs/80_FileUpload.md

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
11
# Dynamic Multi File
22

3-
FormBuilder comes with a smart multi file upload type.
3+
FormBuilder comes with a smart multi-file upload type.
44
It allows you to use different adapters/libraries like FineUploader or DropZoneJs.
55

66
## Highlights
77
- File Upload per file type (yes, it's possible to place multiple upload fields per form)
88
- Large File Support: Process chunked files to allow large file uploads
99
- Different adapters: Choose between different upload handler or create a custom one!
1010
- Stateless: no session is required to handle file uploads
11-
- Different storage principals: Store data as pimcore assets (`/formdata` asset folder) and add download-link to mail **or** add them as native mail attachments
11+
- 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
1212
- Stay clean: unsubmitted data / chunk data will be swiped via maintenance
13-
- Prebuild Extensions: Use included jQuery extensions to set up your form in front end in no time!
13+
- Prebuild Extensions: Use included jQuery extensions to set up your form in the front end in no time!
1414

1515
## Field Configuration
1616
There are some options in the (backend) field configuration:
1717

18-
| Name | Description
19-
|------|------------|
20-
| `Max File Size` | Max file size will be calculated in MB. Empty or zero means no limit |
21-
| `Allowed Extensions` | Define allowed extensions, for example: `pdf, zip` (Format depends on active adapter) |
22-
| `Item limit` | The maximum number of files that can be uploaded. Empty or zero means no limit |
18+
| Name | Description |
19+
|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
20+
| `Max File Size` | Max file size will be calculated in MB. Empty or zero means no limit |
21+
| `Allowed Extensions` | Define allowed extensions, for example: `pdf, zip` (Format depends on active adapter) |
22+
| `Item limit` | The maximum number of files that can be uploaded. Empty or zero means no limit |
2323
| `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 |
2424

2525
## Setup
26-
Per default, FineUploader will be used. If you want to change the dmf adapter, you need to define it:
26+
Per default, DropZone will be used. If you want to change the dmf adapter, you need to define it:
2727

2828
```yaml
2929
form_builder:
@@ -34,6 +34,48 @@ form_builder:
3434
By default, you don't need to implement more than the standard initialization, described in [FormBuilder Javascript Core Extension](./91_Javascript.md).
3535
The core extension will try to fetch the handler path, defined by `dynamicMultiFileHandlerOptions.defaultHandlerPath`.
3636

37+
## Security
38+
39+
### Field Reference
40+
41+
```yaml
42+
form_builder:
43+
security:
44+
enable_upload_field_reference: true
45+
```
46+
47+
The Field Reference feature ensures that every uploaded file is associated with an existing form field.
48+
When enabled, the client must send a reference to the corresponding form field along with the file.
49+
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).
50+
51+
> [!CAUTION]
52+
> This option is disabled by default to avoid breaking existing uploads.
53+
> Enable it when you want to enforce field-level validation.
54+
55+
### Server MIME Type Validation
56+
57+
```yaml
58+
form_builder:
59+
security:
60+
enable_upload_server_mime_type_validation: true
61+
```
62+
63+
Server MIME Type Validation enforces that uploaded files match the allowed MIME types defined on the server.
64+
When enabled, the server checks the actual file content rather than relying solely on file extensions provided by the client.
65+
This ensures that only valid file types are accepted, preventing spoofed files or uploads that do not match the allowed formats.
66+
67+
> [!CAUTION]
68+
>Important: When MIME type validation is active, you must list the allowed MIME types in your form field configuration.
69+
> 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`).
70+
> 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.
71+
> Always ensure the MIME types in your configuration match the types you expect to allow for uploads.
72+
73+
> [!CAUTION]
74+
> Note: This option is disabled by default to maintain backward compatibility.
75+
> Enable it when you want the server to strictly validate MIME types for uploaded files.
76+
77+
***
78+
3779
## Available Adapter
3880
- [DropZoneJs](./DynamicMultiFile/01_DropZoneJs.md)
3981
- [FineUploader](./DynamicMultiFile/02_FineUploader.md)

src/DependencyInjection/Configuration.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public function getConfigTreeBuilder()
3939
$rootNode->append($this->createPersistenceNode());
4040
$rootNode->append($this->buildFlagsNode());
4141
$rootNode->append($this->buildSpamProductionNode());
42+
$rootNode->append($this->buildSecurityNode());
4243
$rootNode->append($this->buildAreaNode());
4344
$rootNode->append($this->buildFormConfigurationNode());
4445
$rootNode->append($this->buildAdminConfigurationNode());
@@ -608,6 +609,21 @@ private function buildAreaNode(): NodeDefinition
608609
return $rootNode;
609610
}
610611

612+
private function buildSecurityNode(): NodeDefinition
613+
{
614+
$builder = new TreeBuilder('security');
615+
616+
$rootNode = $builder->getRootNode();
617+
$rootNode
618+
->addDefaultsIfNotSet()
619+
->children()
620+
->booleanNode('enable_upload_field_reference')->defaultFalse()->end()
621+
->booleanNode('enable_upload_server_mime_type_validation')->defaultFalse()->end()
622+
->end();
623+
624+
return $rootNode;
625+
}
626+
611627
private function buildSpamProductionNode(): NodeDefinition
612628
{
613629
$builder = new TreeBuilder('spam_protection');

src/DynamicMultiFile/Adapter/DropZoneAdapter.php

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,15 @@ public function getJsHandler(): string
4141
public function onUpload(Request $request): Response
4242
{
4343
$result = $this->fileStream->handleUpload([
44-
'binary' => 'dmfData',
45-
'uuid' => 'uuid',
46-
'chunkIndex' => 'dzchunkindex',
47-
'totalChunkCount' => 'dztotalchunkcount',
48-
'totalFileSize' => 'dztotalfilesize',
44+
'binary' => 'dmfData',
45+
'uuid' => 'uuid',
46+
'fieldReferenceKey' => 'fieldReference',
47+
'chunkIndex' => 'dzchunkindex',
48+
'totalChunkCount' => 'dztotalchunkcount',
49+
'totalFileSize' => 'dztotalfilesize',
4950
]);
5051

51-
return new JsonResponse($result);
52+
return new JsonResponse($result, $result['statusCode'] ?? 200);
5253
}
5354

5455
public function onDone(Request $request): Response
@@ -62,10 +63,30 @@ public function onDone(Request $request): Response
6263
public function onDelete(Request $request): Response
6364
{
6465
$identifier = $request->attributes->get('identifier');
65-
$checkChunkFolder = $request->request->get('uploadStatus') === 'canceled';
6666

67-
$result = $this->fileStream->handleDelete($identifier, $checkChunkFolder);
67+
$uploadStatus = null;
68+
$checkChunkFolder = false;
69+
$fieldReference = null;
6870

69-
return new JsonResponse($result);
71+
if ($request->request->has('uploadStatus')) {
72+
$uploadStatus = $request->request->get('uploadStatus');
73+
$checkChunkFolder = $uploadStatus === 'canceled';
74+
}
75+
76+
if ($request->request->has('fieldReference')) {
77+
$fieldReference = $request->request->get('fieldReference');
78+
}
79+
80+
try {
81+
$body = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
82+
$checkChunkFolder = ($body['uploadStatus'] ?? $uploadStatus) === 'canceled';
83+
$fieldReference = $body['fieldReference'] ?? $fieldReference;
84+
} catch (\Throwable) {
85+
// fail silently
86+
}
87+
88+
$result = $this->fileStream->handleDelete($identifier, $checkChunkFolder, $fieldReference);
89+
90+
return new JsonResponse($result, $result['statusCode'] ?? 200);
7091
}
7192
}

src/DynamicMultiFile/Adapter/FineUploadAdapter.php

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,15 @@ public function onUpload(Request $request): Response
4444

4545
if ($method === 'POST') {
4646
$result = $this->fileStream->handleUpload([
47-
'binary' => 'qqfile',
48-
'uuid' => 'qquuid',
49-
'chunkIndex' => 'qqpartindex',
50-
'totalChunkCount' => 'qqtotalparts',
51-
'totalFileSize' => 'qqtotalfilesize',
47+
'binary' => 'qqfile',
48+
'uuid' => 'qquuid',
49+
'fieldReferenceKey' => 'fieldReference',
50+
'chunkIndex' => 'qqpartindex',
51+
'totalChunkCount' => 'qqtotalparts',
52+
'totalFileSize' => 'qqtotalfilesize',
5253
], false);
5354

54-
return new JsonResponse($result);
55+
return new JsonResponse($result, $result['statusCode'] ?? 200);
5556
}
5657

5758
if ($method === 'DELETE') {
@@ -64,14 +65,15 @@ public function onUpload(Request $request): Response
6465
public function onDone(Request $request): Response
6566
{
6667
$result = $this->fileStream->combineChunks([
67-
'fileName' => $request->request->get('qqfilename'),
68-
'uuid' => 'qquuid',
69-
'chunkIndex' => 'qqpartindex',
70-
'totalChunkCount' => 'qqtotalparts',
71-
'totalFileSize' => 'qqtotalfilesize',
68+
'fileName' => $request->request->get('qqfilename'),
69+
'uuid' => 'qquuid',
70+
'fieldReferenceKey' => 'fieldReference',
71+
'chunkIndex' => 'qqpartindex',
72+
'totalChunkCount' => 'qqtotalparts',
73+
'totalFileSize' => 'qqtotalfilesize',
7274
]);
7375

74-
return new JsonResponse($result, $result['statusCode']);
76+
return new JsonResponse($result, $result['statusCode'] ?? 200);
7577
}
7678

7779
public function onDelete(Request $request): Response
@@ -80,8 +82,10 @@ public function onDelete(Request $request): Response
8082
? $request->attributes->get('identifier')
8183
: $request->request->get('uuid');
8284

83-
$result = $this->fileStream->handleDelete($identifier);
85+
$fieldReference = $request->query->get('fieldReference');
8486

85-
return new JsonResponse($result);
87+
$result = $this->fileStream->handleDelete($identifier, false, $fieldReference);
88+
89+
return new JsonResponse($result, $result['statusCode'] ?? 200);
8690
}
8791
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
/*
4+
* This source file is available under two different licenses:
5+
* - GNU General Public License version 3 (GPLv3)
6+
* - DACHCOM Commercial License (DCL)
7+
* Full copyright and license information is available in
8+
* LICENSE.md which is distributed with this source code.
9+
*
10+
* @copyright Copyright (c) DACHCOM.DIGITAL AG (https://www.dachcom-digital.com)
11+
* @license GPLv3 and DCL
12+
*/
13+
14+
namespace FormBuilderBundle\Exception;
15+
16+
class UploadErrorException extends \Exception
17+
{
18+
}

src/Form/Type/DynamicMultiFileType.php

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@
1515

1616
use FormBuilderBundle\Configuration\Configuration;
1717
use FormBuilderBundle\Event\Form\FormTypeOptionsEvent;
18+
use FormBuilderBundle\Form\Data\FormData;
1819
use FormBuilderBundle\FormBuilderEvents;
1920
use FormBuilderBundle\Registry\DynamicMultiFileAdapterRegistry;
2021
use Symfony\Component\Form\AbstractType;
2122
use Symfony\Component\Form\CallbackTransformer;
2223
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
2324
use Symfony\Component\Form\FormBuilderInterface;
25+
use Symfony\Component\Form\FormEvent;
26+
use Symfony\Component\Form\FormEvents;
2427
use Symfony\Component\Form\FormFactoryInterface;
2528
use Symfony\Component\OptionsResolver\OptionsResolver;
2629
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
@@ -49,15 +52,32 @@ public function configureOptions(OptionsResolver $resolver): void
4952

5053
public function buildForm(FormBuilderInterface $builder, array $options): void
5154
{
55+
$builder->addEventListener(FormEvents::POST_SET_DATA, [$this, 'handleDynamicMultiFileForm']);
56+
}
57+
58+
public function handleDynamicMultiFileForm(FormEvent $event): void
59+
{
60+
$adapterFormFieldName = 'adapter';
61+
5262
$dmfAdapterName = $this->configuration->getConfig('dynamic_multi_file_adapter');
5363
$dmfAdapter = $this->dynamicMultiFileAdapterRegistry->get($dmfAdapterName);
5464

65+
$options = $event->getForm()->getConfig()->getOptions();
66+
5567
$options['compound'] = true;
68+
$options['auto_initialize'] = false;
5669
$options['label'] = empty($options['label']) ? false : $options['label'];
5770
$options['attr']['data-dynamic-multi-file-instance'] = 'true';
5871
$options['attr']['data-js-handler'] = $dmfAdapter->getJsHandler();
5972

60-
$adapterFormFieldName = 'adapter';
73+
$rootForm = $event->getForm()->getRoot();
74+
if ($rootForm->getData() instanceof FormData) {
75+
$options['attr']['data-field-reference'] = sprintf(
76+
'%s:%s',
77+
$rootForm->getData()->getFormDefinition()->getId(),
78+
$event->getForm()->getName()
79+
);
80+
}
6181

6282
$dmfForm = $this->formFactory->createNamedBuilder(
6383
$adapterFormFieldName,
@@ -66,7 +86,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
6686
$this->dispatchFormTypeOptionsEvent($adapterFormFieldName, $dmfAdapter->getForm(), $options)
6787
);
6888

69-
$dmfForm->add('data', HiddenType::class, []);
89+
$dmfForm->add('data', HiddenType::class);
7090
$dmfForm->get('data')->addModelTransformer(new CallbackTransformer(
7191
function ($identifier) {
7292
return $identifier === null ? null : json_encode($identifier, JSON_THROW_ON_ERROR);
@@ -76,7 +96,7 @@ function ($identifier) {
7696
}
7797
));
7898

79-
$builder->add($dmfForm);
99+
$event->getForm()->add($dmfForm->getForm());
80100
}
81101

82102
private function dispatchFormTypeOptionsEvent(string $name, string $type, array $options): array

0 commit comments

Comments
 (0)