Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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$'
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion assets/js/version.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// generated by genversion
export const version = '6.1.2';
export const version = '6.1.3';
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion config/packages/framework.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bolt",
"version": "6.1.2",
"version": "6.1.3",
"homepage": "https://boltcms.io",
"author": "Bob den Otter <bob@twokings.nl> (https://boltcms.io)",
"license": "MIT",
Expand Down
14 changes: 1 addition & 13 deletions phpstan-baseline.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion public/theme/skeleton/partials/_image.twig
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{% if image %}
<figure>
<a href="{{ record|image }}">
<img src="{{ thumbnail(record, 1368, 1026) }}" alt="{{ (record|image).alt|default(record|title) }}">
<img src="{{ thumbnail(record, 1368, 1026, quality = 70, format = 'avif') }}" alt="{{ (record|image).alt|default(record|title) }}">
</a>
{% if image.alt|default() %}
<figcaption>{{ image.alt }}</figcaption>
Expand Down
26 changes: 20 additions & 6 deletions src/Controller/Backend/Async/UploadController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'
);

Expand Down Expand Up @@ -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)), '<script') === false;
// Sanitize the SVG content
if (false === $sanitizedSvg = $sanitizer->sanitize($svgFile)) {
return false;
}

// Write the sanitized SVG back to the temporary file
file_put_contents($file['tmp_name'], $sanitizedSvg);

return true;
}
}
9 changes: 7 additions & 2 deletions src/Controller/Backend/FilemanagerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
93 changes: 71 additions & 22 deletions src/Controller/ImageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@

class ImageController
{
private const SUPPORTED_FORMATS = ['jpg', 'webp', 'png', 'gif', 'avif'];

private Server $server;

/**
* @var array{
* w?: int,
* h?: int,
* fit?: string,
* fm?: string,
* location?: string,
* q?: int
* }
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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'),
Comment thread
kouz75 marked this conversation as resolved.
'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;
}
}
}
Expand All @@ -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) {
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/Twig/ImageExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Loading
Loading