diff --git a/.env b/.env index 248254344..a207060a6 100644 --- a/.env +++ b/.env @@ -60,3 +60,7 @@ MAILER_DSN=smtp://localhost # Set canonical in the general config. Keep empty to not use it. BOLT_CANONICAL= + +# Ensure you set your trusted hosts to prevent Host Header Injection +# See https://symfony.com/doc/current/reference/configuration/framework.html#trusted-hosts +#SYMFONY_TRUSTED_HOSTS='^example\.com$' diff --git a/CHANGELOG.md b/CHANGELOG.md index 432c5fa3a..04b998e49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,33 @@ Changelog ========= +## 6.1.3 + +Released: 2026-05-16 + +This release includes security-related fixes. Our thanks to @chndlrx and @kouz75 for identifying these issues and disclosing them to us responsibly! 👏🙏 + +### 🔐 Security related changes + +- Prevent user to create/delete folder/files anywhere (@kouz75, https://github.com/bolt/core/pull/3717) +- Switch to SVG-sanitation library to sanitise uploaded SVG files (@bobvandevijver, https://github.com/bolt/core/pull/3723) +- Enable SYMFONY_TRUSTED_HOSTS configuration by default (@bobvandevijver, https://github.com/bolt/core/pull/3723) + +## 6.1.2 + +Released: 2026-04-27 + +Bugfix release for MySQL platform. + - [#3710](https://github.com/bolt/core/pull/3710) + - [#3713](https://github.com/bolt/core/pull/3713) + +## 6.1.1 + +Released: 2026-04-24 + +Maintenance release bumping some dependencies. +Bolt now also disables the save button during form submission. + ## 6.1.0 Released: 2026-04-09 diff --git a/assets/js/version.js b/assets/js/version.js index 7d1f5d2e1..f946659d5 100644 --- a/assets/js/version.js +++ b/assets/js/version.js @@ -1,2 +1,2 @@ // generated by genversion -export const version = '6.1.2'; +export const version = '6.1.3'; diff --git a/composer.json b/composer.json index e9fc7d417..b78149b36 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "doctrine/orm": "^3.5", "drupol/composer-packages": "^2.0", "embed/embed": "^4.4", + "enshrined/svg-sanitize": "^0.22.0", "erusev/parsedown": "^1.8", "erusev/parsedown-extra": "^0.9", "fakerphp/faker": "^1.16", diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 00f1e2b0a..5197c7203 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -3,7 +3,8 @@ framework: secret: '%env(APP_SECRET)%' csrf_protection: { enabled: true } http_method_override: true - trusted_hosts: ~ + trusted_hosts: + - '%env(default::SYMFONY_TRUSTED_HOSTS)%' # Enables session support. Note that the session will ONLY be started if you read or write from it. # Remove or comment this section to explicitly disable session support. diff --git a/package-lock.json b/package-lock.json index 02ea38cd6..a64c15d23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24785,9 +24785,9 @@ } }, "node_modules/systeminformation": { - "version": "5.31.1", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.1.tgz", - "integrity": "sha512-6pRwxoGeV/roJYpsfcP6tN9mep6pPeCtXbUOCdVa0nme05Brwcwdge/fVNhIZn2wuUitAKZm4IYa7QjnRIa9zA==", + "version": "5.31.6", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.6.tgz", + "integrity": "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA==", "dev": true, "license": "MIT", "os": [ diff --git a/package.json b/package.json index 489be291d..a3ba18e95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bolt", - "version": "6.1.2", + "version": "6.1.3", "homepage": "https://boltcms.io", "author": "Bob den Otter (https://boltcms.io)", "license": "MIT", diff --git a/phpstan-baseline.php b/phpstan-baseline.php index 2e9d4aef6..ab0883bc4 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -1076,23 +1076,11 @@ 'path' => __DIR__ . '/src/Controller/Backend/Async/UploadController.php', ]; $ignoreErrors[] = [ - 'message' => '#^Method Bolt\\\\Controller\\\\Backend\\\\Async\\\\UploadController\\:\\:checkJavascriptInSVG\\(\\) has parameter \\$file with no value type specified in iterable type array\\.$#', + 'message' => '#^Method Bolt\\\\Controller\\\\Backend\\\\Async\\\\UploadController\\:\\:sanitizeSvgContent\\(\\) has parameter \\$file with no value type specified in iterable type array\\.$#', 'identifier' => 'missingType.iterableValue', 'count' => 1, 'path' => __DIR__ . '/src/Controller/Backend/Async/UploadController.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Parameter \\#1 \\$string of function mb_strtolower expects string, string\\|false given\\.$#', - 'identifier' => 'argument.type', - 'count' => 1, - 'path' => __DIR__ . '/src/Controller/Backend/Async/UploadController.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Parameter \\#2 \\$subject of function preg_match expects string, string\\|false given\\.$#', - 'identifier' => 'argument.type', - 'count' => 1, - 'path' => __DIR__ . '/src/Controller/Backend/Async/UploadController.php', -]; $ignoreErrors[] = [ 'message' => '#^Method Bolt\\\\Controller\\\\Backend\\\\BulkOperationsController\\:\\:findRecordsFromIds\\(\\) has parameter \\$ids with no value type specified in iterable type array\\.$#', 'identifier' => 'missingType.iterableValue', diff --git a/public/theme/skeleton/partials/_image.twig b/public/theme/skeleton/partials/_image.twig index d697419b6..18205ecfd 100644 --- a/public/theme/skeleton/partials/_image.twig +++ b/public/theme/skeleton/partials/_image.twig @@ -1,7 +1,7 @@ {% if image %}
- {{ (record|image).alt|default(record|title) }} + {{ (record|image).alt|default(record|title) }} {% if image.alt|default() %}
{{ image.alt }}
diff --git a/src/Controller/Backend/Async/UploadController.php b/src/Controller/Backend/Async/UploadController.php index b1445d2ac..389ef159e 100644 --- a/src/Controller/Backend/Async/UploadController.php +++ b/src/Controller/Backend/Async/UploadController.php @@ -11,6 +11,7 @@ use Bolt\Twig\TextExtension; use Cocur\Slugify\Slugify; use Doctrine\ORM\EntityManagerInterface; +use enshrined\svgSanitize\Sanitizer; use Sirius\Upload\Handler; use Sirius\Upload\Result\File; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -150,8 +151,8 @@ public function handleUpload(Request $request): JsonResponse $uploadHandler->addRule( 'callback', - ['callback' => $this->checkJavascriptInSVG(...)], - 'It is not allowed to upload SVG\'s with embedded Javascript.', + ['callback' => $this->sanitizeSvgContent(...)], + 'The SVG-file could not be sanitized automatically, is it a valid SVG-file?', 'Upload file' ); @@ -210,18 +211,31 @@ private function sanitiseFilename(string $filename): string return $filename . '.' . $extension; } - public function checkJavascriptInSVG(array $file): bool + public function sanitizeSvgContent(array $file): bool { if (Path::getExtension($file['name']) != 'svg') { return true; } - $svgFile = file_get_contents($file['tmp_name']); + // Configure sanitizer + $sanitizer = new Sanitizer(); + $sanitizer->minify(true); + $sanitizer->removeXMLTag(true); + $sanitizer->removeRemoteReferences(true); - if (preg_match('/(?:<[^>]+\s)(on\S+)=["\']?((?:.(?!["\']?\s+(?:\S+)=|[>"\']))+.)["\']?/i', $svgFile)) { + // Retrieve file contents + if (! $svgFile = file_get_contents($file['tmp_name'])) { return false; } - return mb_strpos((string) preg_replace('/\s+/', '', mb_strtolower($svgFile)), 'sanitize($svgFile)) { + return false; + } + + // Write the sanitized SVG back to the temporary file + file_put_contents($file['tmp_name'], $sanitizedSvg); + + return true; } } diff --git a/src/Controller/Backend/FilemanagerController.php b/src/Controller/Backend/FilemanagerController.php index 6374aaa69..b2a5ec30c 100644 --- a/src/Controller/Backend/FilemanagerController.php +++ b/src/Controller/Backend/FilemanagerController.php @@ -20,6 +20,7 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException; @@ -96,11 +97,15 @@ public function delete(Request $request): Response $path = $this->getFromRequest($request, 'path'); $location = $this->getFromRequest($request, 'location'); + if (! is_string($path)) { + throw new BadRequestHttpException('Invalid filename'); + } + $this->denyAccessUnlessGranted('managefiles:' . $location); $location = $this->fileLocations->get($location); - $folder = Path::canonicalize($location->getBasepath() . '/' . $path); + $folder = PathCanonicalize::canonicalize($location->getBasepath(), $path); if (! $this->filesystem->exists($folder)) { $this->addFlash('warning', 'filemanager.delete_folder_missing'); @@ -139,7 +144,7 @@ public function create(Request $request): Response $location = $this->fileLocations->get($location); - $folder = Path::canonicalize($location->getBasepath() . '/' . $path); + $folder = PathCanonicalize::canonicalize($location->getBasepath(), $path); if ($this->filesystem->exists($folder)) { $this->addFlash('warning', 'filemanager.create_folder_already_exists'); diff --git a/src/Controller/ImageController.php b/src/Controller/ImageController.php index 3b3de5dca..6bd8378f0 100644 --- a/src/Controller/ImageController.php +++ b/src/Controller/ImageController.php @@ -20,6 +20,8 @@ class ImageController { + private const SUPPORTED_FORMATS = ['jpg', 'webp', 'png', 'gif', 'avif']; + private Server $server; /** @@ -27,6 +29,7 @@ class ImageController * w?: int, * h?: int, * fit?: string, + * fm?: string, * location?: string, * q?: int * } @@ -45,17 +48,22 @@ public function thumbnail(Request $request, string $paramString, string $filenam return $this->sendErrorImage(); } + $this->parseParameters($paramString); + try { $filename = PathCanonicalize::canonicalize($this->getPath($request), $filename, true); } catch (Exception) { return $this->sendErrorImage(); } - $this->parseParameters($paramString); + $sourceFilename = $this->parseFormatFromFilename($filename); + + $urlFilename = isset($this->parameters['fm']) && $this->parameters['fm'] !== '' ? $sourceFilename . '.' . $this->parameters['fm']:$sourceFilename; + $this->createServer($request); - $this->saveThumb($request, $filename); + $this->saveThumb($request, $sourceFilename, $urlFilename); - return $this->buildResponse($request, $filename); + return $this->buildResponse($request, $sourceFilename); } private function createServer(Request $request): void @@ -82,7 +90,25 @@ private function getPath(Request $request, ?string $path = null, bool $absolute return $this->config->getPath($path, $absolute, $additional); } - private function saveThumb(Request $request, string $filename): void + private function parseFormatFromFilename(string $filename): string + { + $parts = explode('.', pathinfo($filename, PATHINFO_BASENAME)); + + if (count($parts) < 3) { + return $filename; + } + + $ext = mb_strtolower(end($parts)); + + if ($this->isSupportedFormat($ext)) { + $this->parameters['fm'] = $ext; + return mb_substr($filename, 0, -(mb_strlen($ext) + 1)); + } + + return $filename; + } + + private function saveThumb(Request $request, string $filename, string $urlFilename = ''): void { if (! $this->config->get('general/thumbnails/save_files', true)) { return; @@ -95,7 +121,7 @@ private function saveThumb(Request $request, string $filename): void $thumbPath = Path::join( $this->getPath($request, 'thumbs'), $this->parameterPath(), - $filename + $urlFilename ?: $filename ); try { @@ -162,21 +188,38 @@ private function parseParameters(string $paramString): void $this->parameters = [ 'w' => (isset($raw[0]) && is_numeric($raw[0])) ? (int) $raw[0] : 400, 'h' => (isset($raw[1]) && is_numeric($raw[1])) ? (int) $raw[1] : 300, - 'fit' => $raw[2] ?? $this->config->get('general/thumbnails/default_cropping', 'default'), + 'fm' => '', + 'fit' => $this->config->get('general/thumbnails/default_cropping', 'default'), 'location' => 'files', - 'q' => (! empty($raw[2]) && 0 <= $raw[2] && $raw[2] <= 100) ? (int) $raw[2] : 80, + 'q' => $this->config->get('general/thumbnails/quality', 80) ]; - if (isset($raw[4])) { - $this->parameters['fit'] = $this->parseFit($raw[3]); - $this->parameters['location'] = $raw[4]; - } elseif (isset($raw[3])) { - $possibleFit = $this->parseFit($raw[3]); + $remaining = array_values(array_filter( + array_slice($raw, 2), + static fn (int|string $value): bool => $value !== '' + )); + + if (isset($remaining[0]) && is_numeric($remaining[0]) && 0 <= (int) $remaining[0] && (int) $remaining[0] <= 100) { + $this->parameters['q'] = (int) array_shift($remaining); + } + + foreach ($remaining as $token) { + $token = (string) $token; + $normalizedToken = mb_strtolower($token); - if ($this->testFit($possibleFit)) { - $this->parameters['fit'] = $possibleFit; - } else { - $this->parameters['location'] = $raw[3]; + if ($this->parameters['fm'] === '' && $this->isSupportedFormat($normalizedToken)) { + $this->parameters['fm'] = $normalizedToken; + continue; + } + + $fit = $this->parseFit($normalizedToken); + if ($this->testFit($fit)) { + $this->parameters['fit'] = $fit; + continue; + } + + if ($this->parameters['location'] === 'files') { + $this->parameters['location'] = $token; } } } @@ -203,6 +246,11 @@ private function testFit(string $fit): bool return (bool) preg_match('/^(contain|max|fill|stretch|crop)(-.+)?/', $fit); } + private function isSupportedFormat(string $format): bool + { + return in_array($format, self::SUPPORTED_FORMATS, true); + } + public function parseFit(string $fit): string { return match ($fit) { @@ -217,14 +265,15 @@ public function parseFit(string $fit): string private function parameterPath(): string { - return sprintf( - '%d_%d_%d_%s_%s', + $parts = array_filter([ $this->parameters['w'] ?? 0, $this->parameters['h'] ?? 0, - $this->parameters['q'] ?? 0, - $this->parameters['fit'] ?? '', - $this->parameters['location'] ?? '' - ); + $this->parameters['q'] ?? 80, + $this->parameters['fit'] ?? null, + $this->parameters['location'] ?? 'files', + ], fn (int|string|null $v): bool => $v !== null && $v !== '' && $v !== 0); + + return implode('×', $parts); } public function sendErrorImage(): Response diff --git a/src/Twig/ImageExtension.php b/src/Twig/ImageExtension.php index c41e42ac2..889c71e5d 100644 --- a/src/Twig/ImageExtension.php +++ b/src/Twig/ImageExtension.php @@ -102,11 +102,11 @@ public function showImage($image, ?int $width = null, ?int $height = null, ?bool /** * @param ImageField|array|string $image */ - public function thumbnail($image, ?int $width = null, ?int $height = null, ?string $location = null, ?string $path = null, ?string $fit = null, ?int $quality = null): string + public function thumbnail($image, ?int $width = null, ?int $height = null, ?string $location = null, ?string $path = null, ?string $fit = null, ?int $quality = null, ?string $format = null): string { $filename = $this->getFilename($image, true); - return $this->thumbnailHelper->path($filename, $width, $height, $location, $path, $fit, $quality); + return $this->thumbnailHelper->path($filename, $width, $height, $location, $path, $fit, $quality, $format); } /** diff --git a/src/Utils/ThumbnailHelper.php b/src/Utils/ThumbnailHelper.php index ae668d0ce..f222b30b6 100644 --- a/src/Utils/ThumbnailHelper.php +++ b/src/Utils/ThumbnailHelper.php @@ -27,18 +27,22 @@ private function parameters(?int $width = null, ?int $height = null, ?string $fi $height = 10000; } - if ($location === 'files') { - $location = null; + if ($location === null) { + $location = 'files'; } if (! $quality && $this->config instanceof Config) { $quality = (int) $this->config->get('general/thumbnails/quality'); } + if ($fit === null && $this->config instanceof Config) { + $fit = $this->config->get('general/thumbnails/default_cropping', 'default'); + } + return implode('×', array_filter([$width, $height, $quality, $fit, $location])); } - public function path(?string $filename = null, ?int $width = null, ?int $height = null, ?string $location = null, ?string $path = null, ?string $fit = null, ?int $quality = null): string + public function path(?string $filename = null, ?int $width = null, ?int $height = null, ?string $location = null, ?string $path = null, ?string $fit = null, ?int $quality = null, ?string $format = null): string { if (! $filename) { return '/assets/images/placeholder.png'; @@ -47,7 +51,9 @@ public function path(?string $filename = null, ?int $width = null, ?int $height if ($path) { $filename = $path . '/' . $filename; } - + if ($format) { + $filename .= '.' . $format; + } $paramString = $this->parameters($width, $height, $fit, $location, $quality); $filename = Str::ensureStartsWith($filename, '/'); diff --git a/src/Version.php b/src/Version.php index 473e9f7a0..effa27299 100644 --- a/src/Version.php +++ b/src/Version.php @@ -23,7 +23,7 @@ final class Version * Stable — 3.0.0 * Development — 3.1.0 alpha 1 */ - public const VERSION = '6.1.2'; + public const VERSION = '6.1.3'; public const CODENAME = ''; diff --git a/yaml-migrations/m_2026-05-14-framework.yaml b/yaml-migrations/m_2026-05-14-framework.yaml new file mode 100644 index 000000000..6e6e15b58 --- /dev/null +++ b/yaml-migrations/m_2026-05-14-framework.yaml @@ -0,0 +1,7 @@ +file: packages/framework.yaml +since: 6.1.3 + +add: + framework: + trusted_hosts: + - '%env(default::SYMFONY_TRUSTED_HOSTS)%'