From a0843074c3b598db446bb95d5b340a72fc506d2c Mon Sep 17 00:00:00 2001 From: Abdulmajeed-Jamaan <41128358+Abdulmajeed-Jamaan@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:47:51 +0300 Subject: [PATCH 1/2] Ignore AI assistant files and folders --- .gitignore | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.gitignore b/.gitignore index 72b3cf17..1abf773e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,17 @@ yarn-error.log /build .DS_Store /.phpunit.cache + +# ai assistants /.claude +/.codex +/.cursor +/.gemini +/.junie +/.windsurf +/.aider* +/.github/copilot-instructions.md +/.mcp.json +/AGENTS.md +/CLAUDE.md +/GEMINI.md From 7c18d92a207ffdb69fe68284b3c2bd5108d1a397 Mon Sep 17 00:00:00 2001 From: Abdulmajeed-Jamaan <41128358+Abdulmajeed-Jamaan@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:54:55 +0300 Subject: [PATCH 2/2] Fix file uplaod --- config/zeus-bolt.php | 21 +++ resources/lang/ar/forms.php | 9 ++ resources/lang/en/forms.php | 9 ++ src/Fields/Classes/FileUpload.php | 126 ++++++++++++++++- src/Filament/Resources/CategoryResource.php | 1 + tests/FileUploadSecurityTest.php | 144 ++++++++++++++++++++ 6 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 tests/FileUploadSecurityTest.php diff --git a/config/zeus-bolt.php b/config/zeus-bolt.php index cd21f49c..5d7ff03e 100644 --- a/config/zeus-bolt.php +++ b/config/zeus-bolt.php @@ -66,12 +66,33 @@ 'defaultMailable' => FormSubmission::class, + /* + * uploads on a `public` disk are readable by anyone holding the URL. + * point this at a private S3 disk if your forms collect anything sensitive. + */ 'uploadDisk' => env('BOLT_FILESYSTEM_DISK', 'public'), 'uploadDirectory' => env('BOLT_FILESYSTEM_DIRECTORY', 'forms'), 'uploadVisibility' => env('BOLT_FILESYSTEM_VISIBILITY', 'public'), + /* + * the extensions the `file upload` field accepts. anything else is rejected server side. + * adding to this list is a security decision: executables (`php`, `cgi`, `sh`, `exe`) + * risk code execution, and markup (`svg`, `html`, `js`) risks stored xss. + */ + 'uploadAcceptedFileTypes' => [ + 'jpg', 'jpeg', 'png', 'gif', 'webp', + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'txt', 'csv', 'zip', + ], + + /* + * the maximum size, in kilobytes, for every `file upload` field that does not set + * its own. leave null to let livewire's own upload limit govern instead. + */ + 'uploadMaxSize' => null, + /* * if you have installed Bolt Pro, you can enable the presets here */ diff --git a/resources/lang/ar/forms.php b/resources/lang/ar/forms.php index c947f2ce..58ae014f 100644 --- a/resources/lang/ar/forms.php +++ b/resources/lang/ar/forms.php @@ -163,6 +163,15 @@ 'general' => 'خيارات عامة', 'color_type' => 'نوع اللون', 'allow_multiple' => 'السماح بمتعدد', + 'accepted_file_types' => 'أنواع الملفات المسموح بها', + 'accepted_file_types_helper' => 'اتركه فارغًا لقبول جميع أنواع الملفات المسموح بها في هذا الموقع.', + 'max_size' => 'الحد الأقصى لحجم الملف', + 'max_size_helper' => 'اختياري. اتركه فارغًا لاستخدام الحد الافتراضي للرفع في هذا الموقع.', + 'max_size_unit' => 'وحدة الحجم', + 'max_size_units' => [ + 'kb' => 'كيلوبايت', + 'mb' => 'ميجابايت', + ], 'is_inline' => 'في سطر واحد', 'more' => 'مزيد من خيارات الحقل', 'rows' => 'صفوف', diff --git a/resources/lang/en/forms.php b/resources/lang/en/forms.php index 90d51f3c..8e6df8e6 100644 --- a/resources/lang/en/forms.php +++ b/resources/lang/en/forms.php @@ -163,6 +163,15 @@ 'general' => 'General Options', 'color_type' => 'Color Type', 'allow_multiple' => 'Allow Multiple', + 'accepted_file_types' => 'Accepted File Types', + 'accepted_file_types_helper' => 'Leave empty to accept every file type allowed by this site.', + 'max_size' => 'Max File Size', + 'max_size_helper' => 'Optional. Leave empty to use this site\'s default upload limit.', + 'max_size_unit' => 'Size Unit', + 'max_size_units' => [ + 'kb' => 'KB', + 'mb' => 'MB', + ], 'is_inline' => 'Is inline', 'more' => 'More field options', 'rows' => 'Rows', diff --git a/src/Fields/Classes/FileUpload.php b/src/Fields/Classes/FileUpload.php index 6e005efa..1254e39b 100644 --- a/src/Fields/Classes/FileUpload.php +++ b/src/Fields/Classes/FileUpload.php @@ -3,7 +3,11 @@ namespace LaraZeus\Bolt\Fields\Classes; use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; +use Filament\Forms\Components\ToggleButtons; +use Filament\Schemas\Components\Grid; use Filament\Tables\Columns\Column; use Illuminate\Support\Facades\Storage; use LaraZeus\Accordion\Forms\Accordion; @@ -12,6 +16,7 @@ use LaraZeus\Bolt\Fields\FieldsContract; use LaraZeus\Bolt\Models\Field; use LaraZeus\Bolt\Models\FieldResponse; +use Symfony\Component\Mime\MimeTypes; class FileUpload extends FieldsContract { @@ -35,6 +40,27 @@ public static function getOptions(?array $sections = null): array ->schema([ Toggle::make('options.allow_multiple') ->label(__('zeus-bolt::forms.fields.options.allow_multiple')), + Select::make('options.accepted_file_types') + ->label(__('zeus-bolt::forms.fields.options.accepted_file_types')) + ->helperText(__('zeus-bolt::forms.fields.options.accepted_file_types_helper')) + ->multiple() + ->options(fn (): array => self::getAllowedExtensionOptions()), + Grid::make() + ->schema([ + TextInput::make('options.max_size') + ->label(__('zeus-bolt::forms.fields.options.max_size')) + ->helperText(__('zeus-bolt::forms.fields.options.max_size_helper')) + ->numeric() + ->minValue(1), + ToggleButtons::make('options.max_size_unit') + ->label(__('zeus-bolt::forms.fields.options.max_size_unit')) + ->options([ + 'kb' => __('zeus-bolt::forms.fields.options.max_size_units.kb'), + 'mb' => __('zeus-bolt::forms.fields.options.max_size_units.mb'), + ]) + ->default('kb') + ->grouped(), + ]), self::isActive(), self::required(), self::columnSpanFull(), @@ -60,9 +86,44 @@ public static function getOptionsHidden(): array self::hiddenHiddenLabel(), self::hiddenVisibility(), Hidden::make('options.allow_multiple')->default(false), + Hidden::make('options.accepted_file_types')->default([]), + Hidden::make('options.max_size')->default(null), + Hidden::make('options.max_size_unit')->default('kb'), ]; } + /** + * The extensions this application allows to be uploaded. + * + * @return array + */ + protected static function defaultAllowedExtensions(): array + { + $extensions = config('zeus-bolt.uploadAcceptedFileTypes'); + + if (! is_array($extensions)) { + return []; + } + + return array_values(array_unique(array_map( + fn (string $extension): string => strtolower(ltrim($extension, '.')), + $extensions + ))); + } + + /** + * The allow list as select options, keyed by extension so a field stores the + * extension itself rather than its position in the list. + * + * @return array + */ + public static function getAllowedExtensionOptions(): array + { + $allowedExtensions = self::defaultAllowedExtensions(); + + return array_combine($allowedExtensions, $allowedExtensions); + } + public function getResponse(Field $field, FieldResponse $resp): string { $responseValue = filled($resp->response) ? Bolt::isJson($resp->response) ? json_decode($resp->response) : [$resp->response] : []; @@ -91,9 +152,18 @@ public function appendFilamentComponentsOptions($component, $zeusField, bool $ha { parent::appendFilamentComponentsOptions($component, $zeusField, $hasVisibility); + $allowedExtensions = self::getFieldAllowedExtensions($zeusField); + $component->disk(config('zeus-bolt.uploadDisk')) ->directory(config('zeus-bolt.uploadDirectory')) - ->visibility(config('zeus-bolt.uploadVisibility')); + ->visibility(config('zeus-bolt.uploadVisibility')) + ->acceptedFileTypes(self::getMimeTypesForExtensions($allowedExtensions)) + ->rules(['extensions:' . implode(',', $allowedExtensions)]); + + /** An unset max size means no cap of ours; livewire still applies its own. */ + if (($maxSizeInKilobytes = self::getFieldMaxSizeInKilobytes($zeusField)) > 0) { + $component->maxSize($maxSizeInKilobytes); + } if (isset($zeusField->options['allow_multiple']) && $zeusField->options['allow_multiple']) { $component = $component->multiple(); @@ -101,4 +171,58 @@ public function appendFilamentComponentsOptions($component, $zeusField, bool $ha return $component; } + + /** + * The extensions picked for one field, intersected with the allow list so it can only narrow. + * + * @return array + */ + protected static function getFieldAllowedExtensions(Field $zeusField): array + { + $configAllowedExtensions = self::defaultAllowedExtensions(); + $fieldSelectedExtensions = $zeusField->options['accepted_file_types'] ?? []; + + if (! is_array($fieldSelectedExtensions) || blank($fieldSelectedExtensions)) { + return $configAllowedExtensions; + } + + return array_values(array_intersect($configAllowedExtensions, array_map( + fn (string $extension): string => strtolower(ltrim($extension, '.')), + $fieldSelectedExtensions + ))); + } + + /** + * The max size in kilobytes for one field: its own if it sets one, otherwise the + * configured default, otherwise zero to let livewire's limit govern the upload. + */ + protected static function getFieldMaxSizeInKilobytes(Field $zeusField): int + { + $fieldSelectedMaxSize = self::convertToKilobytes( + (int) ($zeusField->options['max_size'] ?? 0), + $zeusField->options['max_size_unit'] ?? null, + ); + + return ($fieldSelectedMaxSize > 0) ? $fieldSelectedMaxSize : (int) config('zeus-bolt.uploadMaxSize'); + } + + protected static function convertToKilobytes(int $size, ?string $unit): int + { + return ($unit === 'mb') ? $size * 1024 : $size; + } + + /** + * The mime types Filament validates the uploaded content against. + * + * @param array $extensions + * @return array + */ + protected static function getMimeTypesForExtensions(array $extensions): array + { + $mimeTypes = MimeTypes::getDefault(); + + return array_values(array_unique(array_merge( + ...array_map(fn (string $extension): array => $mimeTypes->getMimeTypes($extension), $extensions) + ))); + } } diff --git a/src/Filament/Resources/CategoryResource.php b/src/Filament/Resources/CategoryResource.php index 73d00180..dc096091 100644 --- a/src/Filament/Resources/CategoryResource.php +++ b/src/Filament/Resources/CategoryResource.php @@ -97,6 +97,7 @@ public static function form(Schema $schema): Schema ->disk(config('zeus-bolt.uploadDisk')) ->directory(config('zeus-bolt.uploadDirectory')) ->visibility(config('zeus-bolt.uploadVisibility')) + ->image() ->columnSpan(['sm' => 2]) ->label(__('zeus-bolt::category.logo')), ]), diff --git a/tests/FileUploadSecurityTest.php b/tests/FileUploadSecurityTest.php new file mode 100644 index 00000000..b551e233 --- /dev/null +++ b/tests/FileUploadSecurityTest.php @@ -0,0 +1,144 @@ +appendFilamentComponentsOptions( + Filament\Forms\Components\FileUpload::make('test'), + new Field(['name' => 'Test Field', 'options' => $options]), + ); +} + +describe('the allow list bolt applies', function () { + it('ships with nothing executable or renderable on it', function () { + expect(array_intersect(BOLT_DANGEROUS_EXTENSIONS, config('zeus-bolt.uploadAcceptedFileTypes')))->toBeEmpty(); + }); + + it('normalises entries however they are written', function () { + config()->set('zeus-bolt.uploadAcceptedFileTypes', ['.PNG', 'Jpg']); + + expect(boltFileUploadComponent()->getAcceptedFileTypes()) + ->toContain('image/png') + ->toContain('image/jpeg'); + }); + + it('accepts nothing when the allow list is empty or missing', function (mixed $configured) { + config()->set('zeus-bolt.uploadAcceptedFileTypes', $configured); + + expect(boltFileUploadComponent()->getAcceptedFileTypes())->toBeEmpty() + ->and(FileUpload::getAllowedExtensionOptions())->toBeEmpty(); + })->with([ + 'emptied deliberately' => [[]], + 'missing entirely' => [null], + ]); + + /** + * The field editor stores whatever these options are keyed by, and that key is what + * the per field allow list is intersected against. Keyed by position, a saved field + * would resolve to nothing. + */ + it('offers each extension to the field editor keyed by itself', function () { + config()->set('zeus-bolt.uploadAcceptedFileTypes', ['.PNG', 'pdf']); + + expect(FileUpload::getAllowedExtensionOptions())->toBe(['png' => 'png', 'pdf' => 'pdf']); + }); +}); + +describe('a single field', function () { + beforeEach(fn () => config()->set('zeus-bolt.uploadAcceptedFileTypes', ['jpg', 'png', 'pdf'])); + + it('narrows the accepted types', function () { + expect(boltFileUploadComponent(['accepted_file_types' => ['pdf']])->getAcceptedFileTypes()) + ->toContain('application/pdf') + ->not->toContain('image/jpeg'); + }); + + it('cannot widen them, and picks outside the allow list are dropped', function () { + expect(boltFileUploadComponent(['accepted_file_types' => ['php', 'exe']])->getAcceptedFileTypes())->toBeEmpty(); + }); + + /** What the field editor saves has to be what the allow list is intersected against. */ + it('resolves a pick taken straight from the field editor options', function () { + $picked = array_key_first(FileUpload::getAllowedExtensionOptions()); + + expect(boltFileUploadComponent(['accepted_file_types' => [$picked]])->getAcceptedFileTypes()) + ->not->toBeEmpty(); + }); + + it('falls back to the whole allow list when it picks nothing', function () { + expect(boltFileUploadComponent()->getAcceptedFileTypes())->toContain('image/jpeg', 'application/pdf'); + }); + + it('sets its own max size in either kilobytes or megabytes', function (int $size, ?string $unit, int $expected) { + expect(boltFileUploadComponent(['max_size' => $size, 'max_size_unit' => $unit])->getMaxSize())->toBe($expected); + })->with([ + 'kilobytes' => [500, 'kb', 500], + 'megabytes' => [5, 'mb', 5120], + 'no unit means kilobytes' => [500, null, 500], + ]); + + /** Setting a size is optional; livewire's own limit governs when nothing is set anywhere. */ + it('leaves the size to livewire when neither it nor the config sets one', function (array $options) { + expect(config('zeus-bolt.uploadMaxSize'))->toBeNull() + ->and(boltFileUploadComponent($options)->getMaxSize())->toBeNull(); + })->with([ + 'nothing set' => [[]], + 'set to zero' => [['max_size' => 0]], + 'left empty' => [['max_size' => null, 'max_size_unit' => 'mb']], + ]); + + it('takes the configured size when it sets none of its own', function () { + config()->set('zeus-bolt.uploadMaxSize', 5000); + + expect(boltFileUploadComponent()->getMaxSize())->toBe(5000) + ->and(boltFileUploadComponent(['max_size' => 0])->getMaxSize())->toBe(5000); + }); + + it('overrides the configured size with its own', function (int $size, ?string $unit, int $expected) { + config()->set('zeus-bolt.uploadMaxSize', 5000); + + expect(boltFileUploadComponent(['max_size' => $size, 'max_size_unit' => $unit])->getMaxSize())->toBe($expected); + })->with([ + 'lower' => [1000, 'kb', 1000], + 'higher' => [50, 'mb', 51200], + ]); +}); + +/** The regression test for the reported issue: a web shell must not survive our rules. */ +it('rejects an uploaded web shell', function () { + Storage::fake(FileUploadConfiguration::disk()); + Storage::disk(FileUploadConfiguration::disk())->put( + FileUploadConfiguration::path('shell.php', withS3Root: false), + '', + ); + + $error = null; + $fail = function (string $message) use (&$error): void { + $error ??= $message; + }; + + foreach (boltFileUploadComponent()->getValidationRules() as $rule) { + if ($rule instanceof Closure) { + $rule('test', [TemporaryUploadedFile::createFromLivewire('/shell.php')], $fail); + } + } + + expect($error)->not->toBeNull(); +});