Skip to content
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
1 change: 1 addition & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 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)
- **[SECURITY]** introduce policy validation for uploads. Read more about it [here](./docs/80_FileUpload.md#upload-policy-validator)

## 5.3.3
- **[BUGFIX]** Sanitize form field values by removing template tags during output transformation
Expand Down
74 changes: 74 additions & 0 deletions docs/80_FileUpload.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,80 @@ This ensures that only valid file types are accepted, preventing spoofed files o
> 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.

### Upload Policy Validator
The Upload Policy Validator allows projects to define custom security and policy rules for file uploads, such as IP-based rate limits or user-specific upload rules.

The bundle itself contains no validation logic or infrastructure dependencies (e.g., cache, Redis, database).
Projects provide their own implementation and register it via bundle configuration.

#### Example: IP-Based Rate Limiting
A common use case is limiting the number of uploads per IP address within a certain time window.
This can be implemented easily using the Symfony RateLimiter component.

To keep the limiter lightweight, in this example we use a dedicated APCu cache service.


```yaml
form_builder:
security:
upload_policy_validator: App\Formbuilder\PolicyValidator\UploadedFilePolicyValidator
```

> [!NOTE]
> The service must implement the interface `FormBuilderBundle\Validator\Policy\UploadPolicyValidatorInterface`.

This configuration defines a sliding-window rate limiter that
allows a maximum of 10 uploads per 5 minutes per client:

```yaml
# config/packages/rate_limiter.yaml:
framework:
rate_limiter:
form_builder_upload:
policy: sliding_window
limit: 10
interval: '5 minutes'
cache_pool: cache.form_builder_upload_rate_limiter

services:
cache.form_builder_upload_rate_limiter:
class: Symfony\Component\Cache\Adapter\ApcuAdapter
arguments:
- 'form_builder_upload'
- 0 # TTL is set to 0 so entries are not automatically evicted. The RateLimiter manages expiration internally.
```

```php
namespace App\Formbuilder\PolicyValidator;

use FormBuilderBundle\Validator\Policy\UploadPolicyValidatorInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use FormBuilderBundle\Stream\Upload\UploadedFileInterface;

class UploadedFilePolicyValidator implements UploadPolicyValidatorInterface
{
public function __construct(private RateLimiterFactory $formBuilderUploadLimiter)
{}

public function validate(UploadedFileInterface $file, ?Request $request = null): void
{
$limiter = $this->formBuilderUploadLimiter->create($request->getClientIp());
$limit = $limiter->consume(1);

if (!$limit->isAccepted()) {
throw new TooManyRequestsHttpException(
null,
'Rate limit exceeded. Please wait before uploading more files.'
null,
429
);
}
}
}
```

***

## Available Adapter
Expand Down
1 change: 1 addition & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,7 @@ private function buildSecurityNode(): NodeDefinition
->children()
->booleanNode('enable_upload_field_reference')->defaultFalse()->end()
->booleanNode('enable_upload_server_mime_type_validation')->defaultFalse()->end()
->scalarNode('upload_policy_validator')->defaultNull()->end()
->end();

return $rootNode;
Expand Down
19 changes: 19 additions & 0 deletions src/DependencyInjection/FormBuilderExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@

use FormBuilderBundle\Configuration\Configuration as BundleConfiguration;
use FormBuilderBundle\Registry\ConditionalLogicRegistry;
use FormBuilderBundle\Validator\Policy\PolicyValidator;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
use Symfony\Component\DependencyInjection\Reference;
Expand Down Expand Up @@ -81,6 +83,8 @@ public function load(array $configs, ContainerBuilder $container): void

$container->setParameter('form_builder.persistence.doctrine.enabled', true);
$container->setParameter('form_builder.persistence.doctrine.manager', $entityManagerName);

$this->setupPolicyValidator($container, $config);
}

private function buildEmailCheckerStack(ContainerBuilder $container, array $config): void
Expand All @@ -102,4 +106,19 @@ private function buildEmailCheckerStack(ContainerBuilder $container, array $conf
$loader->load('config/email_checker.yaml');
$loader->load('services/email_checker.yaml');
}

private function setupPolicyValidator(ContainerBuilder $container, array $config): void
{
$policyValidator = new Definition();
$policyValidator->setClass(PolicyValidator::class);

$uploadPolicyValidator = [];
if ($config['security']['upload_policy_validator'] !== null) {
$uploadPolicyValidator['$uploadPolicyValidator'] = new Reference($config['security']['upload_policy_validator']);
}

$policyValidator->setArguments($uploadPolicyValidator);

$container->setDefinition(PolicyValidator::class, $policyValidator);
}
}
72 changes: 65 additions & 7 deletions src/Stream/FileStream.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
use FormBuilderBundle\Manager\FormDefinitionManager;
use FormBuilderBundle\Model\FormDefinitionInterface;
use FormBuilderBundle\Model\FormFieldDefinitionInterface;
use FormBuilderBundle\Stream\Upload\LocalFile;
use FormBuilderBundle\Stream\Upload\ServerFile;
use FormBuilderBundle\Validator\Policy\PolicyValidator;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
use League\Flysystem\StorageAttributes;
Expand All @@ -38,7 +41,8 @@ public function __construct(
protected FilesystemOperator $formBuilderChunkStorage,
protected FilesystemOperator $formBuilderFilesStorage,
protected MimeTypeGuesserInterface $mimeTypeGuesser,
protected FormDefinitionManager $formDefinitionManager
protected FormDefinitionManager $formDefinitionManager,
protected PolicyValidator $policyValidator,
) {
}

Expand Down Expand Up @@ -148,14 +152,19 @@ public function handleUpload(array $options = [], bool $instantChunkCombining =
];
}

$totalParts = $mainRequest->request->has($options['totalChunkCount']) ? (int) $mainRequest->request->get($options['totalChunkCount']) : 1;
$totalParts = $mainRequest->request->has($options['totalChunkCount'])
? (int) $mainRequest->request->get($options['totalChunkCount'])
: 1;

if ($totalParts > 1) {
// chunked upload
$partIndex = (int) $mainRequest->request->get($options['chunkIndex']);

try {
$this->formBuilderChunkStorage->write(sprintf('%s%s%s', $uuid, DIRECTORY_SEPARATOR, $partIndex), file_get_contents($file->getPathname()));
$this->formBuilderChunkStorage->write(
sprintf('%s%s%s', $uuid, DIRECTORY_SEPARATOR, $partIndex),
file_get_contents($file->getPathname())
);
} catch (FilesystemException $e) {
return [
'success' => false,
Expand Down Expand Up @@ -190,7 +199,21 @@ public function handleUpload(array $options = [], bool $instantChunkCombining =
}

try {
$this->formBuilderFilesStorage->write($uuid . '/' . $serverFileSafeName, file_get_contents($file->getPathname()));
$this->validateUploadPolicy($file, $fileSafeName, $mainRequest);
} 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,
Expand Down Expand Up @@ -324,6 +347,20 @@ public function combineChunks(array $options = []): array
];
}

try {
$this->validateUploadPolicy($filePath, $fileSafeName, $mainRequest);
} catch (UploadErrorException $e) {

$this->removeUploadDirectories($uuid);

return [
'success' => false,
'statusCode' => $e->getCode(),
'fileName' => $fileSafeName,
'error' => $e->getMessage(),
];
}

return [
'success' => true,
'uuid' => $uuid,
Expand Down Expand Up @@ -442,6 +479,9 @@ protected function validateUploadedFileMimeType(UploadedFile $file, array $allow
{
$fileMimeType = null;

$file->getMimeType();
$file->getClientMimeType();

try {
$fileMimeType = $this->mimeTypeGuesser->guessMimeType($file->getPathname());
} catch (\Throwable) {
Expand All @@ -458,9 +498,9 @@ protected function validateUploadedFileSize(UploadedFile $file, ?int $allowedFil
{
$filesize = $file->getSize();

$totalFileSize = $options['totalFileSize'] ?? null;
if ($totalFileSize !== null && $request->request->has($totalFileSize)) {
$filesize = $request->request->get($totalFileSize);
$totalFileSizeKey = $options['totalFileSize'] ?? null;
if ($totalFileSizeKey !== null && $request->request->has($totalFileSizeKey)) {
$filesize = $request->request->get($totalFileSizeKey);
}

$filesize = is_numeric($filesize) ? (int) $filesize : null;
Expand Down Expand Up @@ -528,6 +568,24 @@ protected function validateMimeType(?string $fileMimeType, array $allowedMimeTyp
}
}

/**
* @throws UploadErrorException
*/
protected function validateUploadPolicy($data, string $filename, Request $request): void
{
if ($data instanceof UploadedFile) {
$file = new LocalFile($data, $filename);
} else {
$file = new ServerFile($this->formBuilderFilesStorage, $data, $filename);
}

try {
$this->policyValidator->validateUploadedFile($file, ['request' => $request]);
} catch (\Throwable $e) {
throw new UploadErrorException($e->getMessage(), !empty($e->getCode()) ? $e->getCode() : 400, $e);
}
}

/**
* @throws UploadErrorException
*/
Expand Down
47 changes: 47 additions & 0 deletions src/Stream/Upload/LocalFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

/*
* This source file is available under two different licenses:
* - GNU General Public License version 3 (GPLv3)
* - DACHCOM Commercial License (DCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) DACHCOM.DIGITAL AG (https://www.dachcom-digital.com)
* @license GPLv3 and DCL
*/

namespace FormBuilderBundle\Stream\Upload;

use Symfony\Component\HttpFoundation\File\UploadedFile;

readonly class LocalFile implements UploadedFileInterface
{
public function __construct(
private UploadedFile $file,
private string $originalName
) {
}

public function getSize(): int
{
return $this->file->getSize();
}

public function getMimeType(): ?string
{
return $this->file->getMimeType();
}

public function getOriginalName(): string
{
return $this->originalName;
}

public function getStream(): mixed
{
return fopen($this->file->getRealPath(), 'rb');
}
}
48 changes: 48 additions & 0 deletions src/Stream/Upload/ServerFile.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

/*
* This source file is available under two different licenses:
* - GNU General Public License version 3 (GPLv3)
* - DACHCOM Commercial License (DCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) DACHCOM.DIGITAL AG (https://www.dachcom-digital.com)
* @license GPLv3 and DCL
*/

namespace FormBuilderBundle\Stream\Upload;

use League\Flysystem\FilesystemOperator;

readonly class ServerFile implements UploadedFileInterface
{
public function __construct(
private FilesystemOperator $storage,
private string $filePath,
private string $originalName
) {
}

public function getSize(): int
{
return $this->storage->fileSize($this->filePath);
}

public function getMimeType(): ?string
{
return $this->storage->mimeType($this->filePath);
}

public function getOriginalName(): string
{
return $this->originalName;
}

public function getStream(): mixed
{
return $this->storage->readStream($this->filePath);
}
}
30 changes: 30 additions & 0 deletions src/Stream/Upload/UploadedFileInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

/*
* This source file is available under two different licenses:
* - GNU General Public License version 3 (GPLv3)
* - DACHCOM Commercial License (DCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) DACHCOM.DIGITAL AG (https://www.dachcom-digital.com)
* @license GPLv3 and DCL
*/

namespace FormBuilderBundle\Stream\Upload;

interface UploadedFileInterface
{
public function getSize(): int;

public function getMimeType(): ?string;

public function getOriginalName(): string;

/**
* @return resource
*/
public function getStream(): mixed;
}
Loading