CVE-2025-54082
Description
marshmallow-packages/nova-tiptap is a rich text editor for Laravel Nova based on tiptap. Prior to 5.7.0, a vulnerability was discovered in the marshmallow-packages/nova-tiptap Laravel Nova package that allows unauthenticated users to upload arbitrary files to any Laravel disk configured in the application. The vulnerability is due to missing authentication middleware (Nova and Nova.Auth) on the /nova-tiptap/api/file upload endpoint, the lack of validation on uploaded files (no MIME/type or extension restrictions), and the ability for an attacker to choose the disk parameter dynamically. This means an attacker can craft a custom form and send a POST request to /nova-tiptap/api/file, supplying a valid CSRF token, and upload executable or malicious files (e.g., .php, binaries) to public disks such as local, public, or s3. If a publicly accessible storage path is used (e.g. S3 with public access, or Laravel’s public disk), the attacker may gain the ability to execute or distribute arbitrary files — amounting to a potential Remote Code Execution (RCE) vector in some environments. This vulnerability was fixed in 5.7.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
marshmallow/nova-tiptapPackagist | < 5.7.0 | 5.7.0 |
manogi/nova-tiptapPackagist | <= 3.2.6 | — |
Affected products
1- Range: 5.0.0, 5.1.0, 5.2.0, …
Patches
1fed42d2f8ebbMerge commit from fork
6 files changed · +638 −44
config/nova-tiptap.php+74 −0 modified@@ -25,4 +25,78 @@ 'disk' => 'public', 'path' => '', ], + + /* + |-------------------------------------------------------------------------- + | File Storage Settings + |-------------------------------------------------------------------------- + | + | Default settings for file storage when not explicitly set on the field. + | + */ + 'file_storage' => [ + 'disk' => 'public', + 'path' => '', + ], + + /* + |-------------------------------------------------------------------------- + | Upload Settings + |-------------------------------------------------------------------------- + | + | Configuration for file uploads + | + */ + 'upload' => [ + /* + | Maximum file size in bytes + | Images: 5MB, Files: 10MB + */ + 'max_image_size' => 5242880, // 5MB + 'max_file_size' => 10485760, // 10MB + + /* + | Allowed file extensions for uploads + */ + 'allowed_image_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'], + 'allowed_file_extensions' => [ + 'pdf', + 'doc', + 'docx', + 'xls', + 'xlsx', + 'ppt', + 'pptx', + 'txt', + 'rtf', + 'csv', + 'zip', + 'rar', + '7z', + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + 'svg', + 'mp3', + 'wav', + 'mp4', + 'avi', + 'mov', + 'wmv' + ], + + /* + | Allowed storage disks for uploads + | For security, only allow specific disks + */ + 'allowed_disks' => ['public', 'local'], + + /* + | Enable strict MIME type checking + | This validates that file content matches the extension + */ + 'strict_mime_checking' => true, + ], ];
README.md+1 −1 modified@@ -47,7 +47,7 @@ Tiptap::make('Content') ### Available Buttons | Button | Description | -|--------------------|-------------------------------------------------------------------------------------------------| +| ------------------ | ----------------------------------------------------------------------------------------------- | | `heading` | Text headings (H1, H2, H3, etc.) | | `color` | Color text formatting | | `backgroundColor` | Background color formatting |
src/Controllers/FilesController.php+203 −21 modified@@ -2,45 +2,227 @@ namespace Marshmallow\Tiptap\Controllers; -use Facades\Marshmallow\Tiptap\FileService; +use Marshmallow\Tiptap\FileService; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; class FilesController { - public function store() + /** + * Allowed file extensions for uploads + */ + private function getAllowedExtensions(): array { - $file = request()->file('file'); + return config('nova-tiptap.upload.allowed_file_extensions', [ + 'pdf', + 'doc', + 'docx', + 'xls', + 'xlsx', + 'ppt', + 'pptx', + 'txt', + 'rtf', + 'csv', + 'zip', + 'rar', + '7z', + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + 'svg', + 'mp3', + 'wav', + 'mp4', + 'avi', + 'mov', + 'wmv' + ]); + } - $disk = request()->disk; + /** + * Maximum file size in bytes + */ + private function getMaxFileSize(): int + { + return config('nova-tiptap.upload.max_file_size', 10485760); // 10MB + } - if (!$disk) { - $disk = config('filesystems.disks.public') ? 'public' : config('filesystems.default'); - } + /** + * Allowed storage disks + */ + private function getAllowedDisks(): array + { + return config('nova-tiptap.upload.allowed_disks', ['public', 'local']); + } - $path = trim(request()->path) ?: ''; + public function store(Request $request) + { + // Validate the request + $validator = Validator::make($request->all(), [ + 'file' => [ + 'required', + 'file', + 'max:' . ($this->getMaxFileSize() / 1024), // Laravel expects KB + function ($attribute, $value, $fail) { + if (!$this->isAllowedFileType($value)) { + $fail('The uploaded file type is not allowed.'); + } + } + ], + 'disk' => 'nullable|string|in:' . implode(',', $this->getAllowedDisks()), + 'path' => 'nullable|string|max:255' + ]); - if (substr($path, 0, 1) == '/') { - $path = substr($path, 1); - } - if (substr($path, strlen($path) - 1) == '/') { - $path = substr($path, 0, strlen($path) - 1); + if ($validator->fails()) { + throw new ValidationException($validator); } - $fileName = $file->getClientOriginalName(); + $file = $request->file('file'); + + // Additional security checks + $this->performSecurityChecks($file); + + // Determine disk with security constraints + $disk = $this->getDisk($request->input('disk')); + + // Sanitize path + $path = $this->sanitizePath($request->input('path', '')); + + // Generate a secure filename + $fileName = $this->generateSecureFilename($file); + + // Ensure unique filename if (Storage::disk($disk)->exists($path . '/' . $fileName)) { - $fileName = FileService::uniqifyName($fileName); + $fileService = new FileService(); + $fileName = $fileService->uniqifyName($fileName); } - $newPath = $file->storeAs( - $path, - $fileName, - $disk - ); + // Store the file + $newPath = $file->storeAs($path, $fileName, $disk); $url = Storage::disk($disk)->url($newPath); - return [ + return response()->json([ 'url' => $url, + ]); + } + + /** + * Check if the uploaded file type is allowed + */ + private function isAllowedFileType($file): bool + { + $extension = strtolower($file->getClientOriginalExtension()); + return in_array($extension, $this->getAllowedExtensions()); + } + + /** + * Perform additional security checks on the uploaded file + */ + private function performSecurityChecks($file): void + { + // Check file size + if ($file->getSize() > $this->getMaxFileSize()) { + throw ValidationException::withMessages([ + 'file' => 'File size exceeds the maximum allowed size.' + ]); + } + + // Check for potentially dangerous file extensions + $dangerousExtensions = ['php', 'phtml', 'php3', 'php4', 'php5', 'pht', 'phar', 'exe', 'bat', 'cmd', 'sh', 'js', 'html', 'htm']; + $extension = strtolower($file->getClientOriginalExtension()); + + if (in_array($extension, $dangerousExtensions)) { + throw ValidationException::withMessages([ + 'file' => 'File type is not allowed for security reasons.' + ]); + } + + // Check MIME type matches extension + $mimeType = $file->getMimeType(); + if (!$this->isValidMimeType($mimeType, $extension)) { + throw ValidationException::withMessages([ + 'file' => 'File content does not match its extension.' + ]); + } + } + + /** + * Validate MIME type against extension + */ + private function isValidMimeType(string $mimeType, string $extension): bool + { + $allowedMimeTypes = [ + 'pdf' => ['application/pdf'], + 'doc' => ['application/msword'], + 'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + 'xls' => ['application/vnd.ms-excel'], + 'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + 'txt' => ['text/plain'], + 'jpg' => ['image/jpeg'], + 'jpeg' => ['image/jpeg'], + 'png' => ['image/png'], + 'gif' => ['image/gif'], + 'zip' => ['application/zip'], ]; + + return isset($allowedMimeTypes[$extension]) && + in_array($mimeType, $allowedMimeTypes[$extension]); + } + + /** + * Get disk with security constraints + */ + private function getDisk(?string $requestedDisk): string + { + // Only allow specific disks for security + if ($requestedDisk && in_array($requestedDisk, $this->getAllowedDisks())) { + return $requestedDisk; + } + + // Default to configured disk or public + return config('nova-tiptap.file_storage.disk') ?: (config('filesystems.disks.public') ? 'public' : 'local'); + } + + /** + * Sanitize the upload path + */ + private function sanitizePath(?string $path): string + { + if (!$path) { + return config('nova-tiptap.file_storage.path', ''); + } + + // Remove dangerous characters and path traversal attempts + $path = preg_replace('/[^a-zA-Z0-9\/_-]/', '', $path); + $path = str_replace(['../', './'], '', $path); + + // Trim slashes + $path = trim($path, '/'); + + return $path; + } + + /** + * Generate a secure filename + */ + private function generateSecureFilename($file): string + { + $originalName = $file->getClientOriginalName(); + $extension = $file->getClientOriginalExtension(); + + // Remove potentially dangerous characters from filename + $baseName = pathinfo($originalName, PATHINFO_FILENAME); + $safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $baseName); + + // Ensure filename is not too long + $safeName = substr($safeName, 0, 100); + + return $safeName . '.' . $extension; } }
src/Controllers/ImagesController.php+181 −21 modified@@ -2,45 +2,205 @@ namespace Marshmallow\Tiptap\Controllers; -use Facades\Marshmallow\Tiptap\FileService; +use Marshmallow\Tiptap\FileService; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; class ImagesController { - public function store() + /** + * Allowed image extensions + */ + private function getAllowedExtensions(): array { - $file = request()->file('file'); + return config('nova-tiptap.upload.allowed_image_extensions', ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']); + } - $disk = request()->disk; + /** + * Maximum file size in bytes + */ + private function getMaxFileSize(): int + { + return config('nova-tiptap.upload.max_image_size', 5242880); // 5MB + } - if (! $disk) { - $disk = config('filesystems.disks.public') ? 'public' : config('filesystems.default'); - } + /** + * Allowed storage disks + */ + private function getAllowedDisks(): array + { + return config('nova-tiptap.upload.allowed_disks', ['public', 'local']); + } - $path = trim(request()->path) ?: ''; + public function store(Request $request) + { + // Validate the request + $validator = Validator::make($request->all(), [ + 'file' => [ + 'required', + 'image', + 'max:' . ($this->getMaxFileSize() / 1024), // Laravel expects KB + function ($attribute, $value, $fail) { + if (!$this->isAllowedImageType($value)) { + $fail('The uploaded image type is not allowed.'); + } + } + ], + 'disk' => 'nullable|string|in:' . implode(',', $this->getAllowedDisks()), + 'path' => 'nullable|string|max:255' + ]); - if (substr($path, 0, 1) == '/') { - $path = substr($path, 1); - } - if (substr($path, strlen($path) - 1) == '/') { - $path = substr($path, 0, strlen($path) - 1); + if ($validator->fails()) { + throw new ValidationException($validator); } - $fileName = $file->getClientOriginalName(); + $file = $request->file('file'); + + // Additional security checks + $this->performSecurityChecks($file); + + // Determine disk with security constraints + $disk = $this->getDisk($request->input('disk')); + + // Sanitize path + $path = $this->sanitizePath($request->input('path', '')); + + // Generate a secure filename + $fileName = $this->generateSecureFilename($file); + + // Ensure unique filename if (Storage::disk($disk)->exists($path . '/' . $fileName)) { - $fileName = FileService::uniqifyName($fileName); + $fileService = new FileService(); + $fileName = $fileService->uniqifyName($fileName); } - $newPath = $file->storeAs( - $path, - $fileName, - $disk - ); + // Store the file + $newPath = $file->storeAs($path, $fileName, $disk); $url = Storage::disk($disk)->url($newPath); - return [ + return response()->json([ 'url' => $url, + ]); + } + + /** + * Check if the uploaded image type is allowed + */ + private function isAllowedImageType($file): bool + { + $extension = strtolower($file->getClientOriginalExtension()); + return in_array($extension, $this->getAllowedExtensions()); + } + + /** + * Perform additional security checks on the uploaded file + */ + private function performSecurityChecks($file): void + { + // Check file size + if ($file->getSize() > $this->getMaxFileSize()) { + throw ValidationException::withMessages([ + 'file' => 'Image size exceeds the maximum allowed size.' + ]); + } + + // Verify it's actually an image using getimagesize + $imageInfo = @getimagesize($file->getPathname()); + if ($imageInfo === false) { + throw ValidationException::withMessages([ + 'file' => 'File is not a valid image.' + ]); + } + + // Check for potentially dangerous file extensions + $extension = strtolower($file->getClientOriginalExtension()); + + // For SVG files, perform additional security checks + if ($extension === 'svg') { + $this->validateSvgFile($file); + } + } + + /** + * Validate SVG file for security issues + */ + private function validateSvgFile($file): void + { + $content = file_get_contents($file->getPathname()); + + // Check for potentially dangerous SVG content + $dangerousPatterns = [ + '/<script/i', + '/javascript:/i', + '/onclick/i', + '/onload/i', + '/onerror/i', + '/<iframe/i', + '/<object/i', + '/<embed/i', + '/xlink:href="data:/i' ]; + + foreach ($dangerousPatterns as $pattern) { + if (preg_match($pattern, $content)) { + throw ValidationException::withMessages([ + 'file' => 'SVG file contains potentially dangerous content.' + ]); + } + } + } + + /** + * Get disk with security constraints + */ + private function getDisk(?string $requestedDisk): string + { + // Only allow specific disks for security + if ($requestedDisk && in_array($requestedDisk, $this->getAllowedDisks())) { + return $requestedDisk; + } + + // Default to configured disk or public + return config('nova-tiptap.image_storage.disk') ?: (config('filesystems.disks.public') ? 'public' : 'local'); + } + + /** + * Sanitize the upload path + */ + private function sanitizePath(?string $path): string + { + if (!$path) { + return config('nova-tiptap.image_storage.path', ''); + } + + // Remove dangerous characters and path traversal attempts + $path = preg_replace('/[^a-zA-Z0-9\/_-]/', '', $path); + $path = str_replace(['../', './'], '', $path); + + // Trim slashes + $path = trim($path, '/'); + + return $path; + } + + /** + * Generate a secure filename + */ + private function generateSecureFilename($file): string + { + $originalName = $file->getClientOriginalName(); + $extension = $file->getClientOriginalExtension(); + + // Remove potentially dangerous characters from filename + $baseName = pathinfo($originalName, PATHINFO_FILENAME); + $safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $baseName); + + // Ensure filename is not too long + $safeName = substr($safeName, 0, 100); + + return $safeName . '.' . $extension; } }
src/FieldServiceProvider.php+1 −1 modified@@ -44,7 +44,7 @@ public function boot() */ protected function routes() { - Route::middleware(['nova']) + Route::middleware(['nova', 'nova.auth']) ->prefix('nova-tiptap/api') ->group(__DIR__ . '/../routes/api.php'); }
UPGRADE-GUIDE.md+178 −0 added@@ -0,0 +1,178 @@ +# Nova TipTap Security Upgrade Guide + +## 🚨 Critical Security Patch - Immediate Action Required + +This guide will help you safely upgrade nova-tiptap to address a **Critical Remote Code Execution vulnerability**. + +## Pre-Upgrade Checklist + +- [ ] **Backup your application** before updating +- [ ] **Review current file upload usage** in your Nova resources +- [ ] **Check which disks you're using** for file storage +- [ ] **Note any custom file types** you need to support +- [ ] **Test in a staging environment** first if possible + +## Step-by-Step Upgrade + +### 1. Update the Package + +```bash +composer update marshmallow/nova-tiptap +``` + +### 2. Publish Updated Configuration + +```bash +php artisan vendor:publish --tag=nova-tiptap-config --force +``` + +**⚠️ Note**: This will overwrite your existing `config/nova-tiptap.php`. If you had custom settings, you'll need to reapply them. + +### 3. Review Upload Configuration + +Edit `config/nova-tiptap.php` and customize the upload settings: + +```php +<?php + +return [ + // ... existing config ... + + 'upload' => [ + // File size limits (in bytes) + 'max_image_size' => 5242880, // 5MB + 'max_file_size' => 10485760, // 10MB + + // Allowed file extensions + 'allowed_image_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'], + 'allowed_file_extensions' => [ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'txt', 'rtf', 'csv', 'zip', 'rar', '7z', + 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', + 'mp3', 'wav', 'mp4', 'avi', 'mov', 'wmv' + ], + + // Allowed storage disks (SECURITY CRITICAL) + 'allowed_disks' => ['public', 'local'], + + // Enable strict MIME type checking + 'strict_mime_checking' => true, + ], +]; +``` + +### 4. Update Disk Configuration (if needed) + +If you were using disks other than `public` or `local`, add them to `allowed_disks`: + +```php +'allowed_disks' => ['public', 'local', 's3'], // Add your disks here +``` + +**⚠️ Security Warning**: Only add disks that should be accessible for uploads. Never add sensitive disks. + +### 5. Update File Type Lists (if needed) + +If you need additional file types, add them to the allowed extensions: + +```php +'allowed_file_extensions' => [ + // ... default types ... + 'ai', 'psd', 'sketch', // Add custom types here +], +``` + +**⚠️ Security Warning**: Never add executable extensions like `php`, `exe`, `sh`, `bat`, etc. + +### 6. Update Your Nova Resources (if needed) + +If you were programmatically setting disks in your Nova resources, ensure they're in the allowed list: + +```php +// In your Nova Resource +Tiptap::make('Content') + ->disk('public') // Make sure this disk is in allowed_disks + ->path('articles'); +``` + +## Breaking Changes + +### Authentication Required + +- **Before**: Anyone could upload files +- **After**: Must be authenticated Nova user + +### File Type Restrictions + +- **Before**: Any file type allowed +- **After**: Only whitelisted extensions allowed + +### Disk Restrictions + +- **Before**: Could upload to any configured disk +- **After**: Only allowed disks accepted + +## Troubleshooting + +### "File type not allowed" errors + +**Solution**: Add the file extension to `allowed_file_extensions` or `allowed_image_extensions` in config. + +### "Unauthorized" errors on uploads + +**Solution**: Ensure users are properly authenticated in Nova before uploading. + +### Files not appearing + +**Solution**: Check that the disk is in `allowed_disks` and properly configured. + +### "Disk not allowed" errors + +**Solution**: Add the disk to `allowed_disks` in the security configuration. + +## Security Validation Checklist + +After upgrading, verify: + +- [ ] Unauthenticated requests to upload endpoints return 401/403 +- [ ] PHP/executable files are rejected +- [ ] Only configured disks are accessible +- [ ] File size limits are enforced +- [ ] Path traversal attempts are blocked +- [ ] Existing functionality still works for authenticated users + +## Rollback Plan (Emergency Only) + +If you encounter critical issues and need to rollback: + +```bash +# 1. Revert to previous version +composer require marshmallow/nova-tiptap:5.6.0 + +# 2. Restore your backup configuration +cp config/nova-tiptap.php.backup config/nova-tiptap.php + +# 3. Clear caches +php artisan config:clear +php artisan route:clear +``` + +**⚠️ WARNING**: Rollback leaves you vulnerable. Only use as a temporary measure while fixing issues. + +## Support + +If you need help with the upgrade: + +1. **Check the logs**: Look for specific error messages in `storage/logs/laravel.log` +2. **Review configuration**: Ensure your security settings match your needs +3. **Test systematically**: Validate each security feature is working +4. **Open an issue**: If you find bugs or need assistance + +## Version History + +- **Before patch**: Vulnerable to RCE +- **After patch**: Secure with authentication and file validation + +--- + +**Remember**: This security patch is critical. Do not delay the upgrade.
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.