CVE-2026-46489
Description
SolidInvoice logo upload lacks validation, allowing admin to upload SVG with embedded JS leading to stored XSS on every page.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
SolidInvoice logo upload lacks validation, allowing admin to upload SVG with embedded JS leading to stored XSS on every page.
Vulnerability
SolidInvoice versions prior to 2.3.17 contain a stored cross-site scripting (XSS) vulnerability in the company logo upload feature. The ImageUploadType form type performs no MIME type check, file extension allowlist, or image content validation. The only check is $value->isValid(), which merely confirms the PHP upload succeeded without a filesystem error. The uploaded file is base64-encoded and stored in the settings table as system/company/logo. On every page render, the app_logo() Twig function injects this value directly into an ` tag with is_safe: ['html']`, bypassing auto-escaping. This allows an authenticated administrator to upload an SVG file containing embedded JavaScript, which executes in every authenticated user's browser [1][2][3].
Exploitation
An attacker must have administrative access to the SolidInvoice instance. They can upload a malicious SVG file (e.g., containing embedded JavaScript) via the company logo upload form. The file is base64-encoded and stored without any sanitization. The GlobalExtension::displayAppLogo() function then renders the logo on every page (including layouts, emails, PDF templates, and OAuth consent screens) by creating a Twig template that outputs the base64 data directly into an ` tag with data:image/{{ type }};base64,{{ logo }}. Because the template is declared as is_safe: ['html']`, the embedded script is not escaped and executes in the context of the application's origin [1][2].
Impact
Successful exploitation results in stored cross-site scripting (XSS) that executes in the browser of every authenticated user who views any page containing the logo. The attacker can steal session cookies, perform actions on behalf of the victim, deface the application, or exfiltrate sensitive data. The script runs with the same origin as the SolidInvoice application, giving it full access to the user's session and any data accessible via the application's API [2].
Mitigation
The vulnerability is patched in SolidInvoice version 2.3.17, released on 2026-06-11. Users should upgrade immediately to this version. No workaround is available for earlier versions. The fix adds proper file type validation and sanitization for uploaded logo files [1][3].
AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2(expand)+ 1 more
- (no CPE)
- (no CPE)range: <2.3.17
Patches
18196c64df58bValidate logo uploads to prevent stored XSS via SVG
5 files changed · +321 −3
.claude/settings.local.json+85 −0 added@@ -0,0 +1,85 @@ +{ + "permissions": { + "allow": [ + "Bash(bin/ecs:*)", + "Bash(bin/phpunit:*)", + "Bash(bin/phpstan:*)", + "Bash(bin/console lint:twig:*)", + "Bash(bin/console cache:clear:*)", + "Bash(bin/console debug:router:*)", + "Bash(bin/console lint:yaml:*)", + "Bash(bin/console debug:container:*)", + "Bash(bin/console lint:container:*)", + "Bash(cat:*)", + "Bash(ls:*)", + "Bash(find:*)", + "Bash(grep:*)", + "Bash(bun run build:*)", + "Bash(gh pr view:*)", + "Bash(gh pr diff:*)", + "Bash(git log:*)", + "Bash(wc:*)", + "mcp__github__pull_request_read", + "mcp__github__issue_read", + "Bash(bun run dev)", + "Bash(php -l:*)", + "Skill(frontend-design:frontend-design)", + "Bash(xargs cat:*)", + "mcp__sentry__find_organizations", + "mcp__sentry__search_issues", + "mcp__sentry__find_projects", + "Skill(sentry:sentry-code-review)", + "mcp__sentry__get_issue_details", + "Bash(bin/console list:*)", + "Bash(composer show:*)", + "Skill(sentry:sentry-setup-tracing)", + "WebFetch(domain:docs.sentry.io)", + "Bash(composer --version)", + "Skill(code-review:code-review)", + "mcp__fff__find_files", + "mcp__fff__grep", + "mcp__solidinvoice__list_resource", + "mcp__solidinvoice__create_invoice", + "mcp__solidinvoice__apply_invoice_transition", + "mcp__solidinvoice__send_invoice_reminder", + "Bash(go version *)", + "Bash(go mod *)", + "Bash(GOFLAGS=\"-mod=mod\" go mod tidy -e)", + "mcp__sentry__get_sentry_resource", + "mcp__solidinvoice__list_invoices_by_status", + "Bash(rtk ls *)", + "Bash(command rtk *)", + "mcp__fff__multi_grep", + "Bash(rtk grep *)", + "Bash(rtk find *)", + "Read(//Users/pierre/projects/SolidWorx/platform/**)", + "Bash(rtk git *)", + "Bash(pwd -P)", + "Bash(vendor/bin/phpunit --filter Feature)", + "Bash(vendor/bin/phpunit -c vendor/solidworx/platform/phpunit.xml.dist --filter Feature)", + "Bash(vendor/solidworx/platform/vendor/bin/phpunit -c vendor/solidworx/platform/phpunit.xml.dist --filter Feature)", + "Bash(vendor/bin/phpunit tests/Bundle/PlatformBundle/Feature/FeatureValueTest.php)", + "Bash(vendor/bin/phpunit)", + "Bash(rtk read *)", + "Bash(vendor/bin/phpunit --filter \"PlanFeatureManager\")" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "case \"$CLAUDE_FILE_PATH\" in *.php) symfony php bin/ecs check --fix --quiet \"$CLAUDE_FILE_PATH\" 2>/dev/null || true ;; esac", + "timeout": 30 + }, + { + "type": "command", + "command": "case \"$CLAUDE_FILE_PATH\" in *.yaml|*.yml) symfony console lint:yaml \"$CLAUDE_FILE_PATH\" ;; esac" + } + ] + } + ] + } +}
.phpunit.cache/test-results+1 −0 addedsrc/CoreBundle/Form/Type/ImageUploadType.php+48 −2 modified@@ -20,15 +20,41 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\Validator\Constraints\File as FileConstraint; +use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\UX\Dropzone\Form\DropzoneType; class ImageUploadType extends AbstractType { + /** + * Allowed raster image MIME types. SVG is intentionally excluded as it can + * contain executable JavaScript which would result in stored XSS when the + * logo is rendered inline as a data: URI. + */ + public const ALLOWED_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + ]; + + public function __construct( + private readonly ValidatorInterface $validator + ) { + } + public function buildForm(FormBuilderInterface $builder, array $options): void { - $builder->addModelTransformer(new class() implements DataTransformerInterface { + $validator = $this->validator; + + $builder->addModelTransformer(new class($validator) implements DataTransformerInterface { private ?string $file = null; + public function __construct( + private readonly ValidatorInterface $validator + ) { + } + public function transform(mixed $value): File { if ($value instanceof Setting) { @@ -49,7 +75,27 @@ public function reverseTransform(mixed $value): ?string } if (! $value->isValid()) { - throw new TransformationFailedException(); + throw new TransformationFailedException($value->getErrorMessage()); + } + + $violations = $this->validator->validate($value, new FileConstraint( + mimeTypes: ImageUploadType::ALLOWED_MIME_TYPES, + mimeTypesMessage: 'The uploaded file must be a JPEG, PNG, GIF or WebP image.', + )); + + if (count($violations) > 0) { + $exception = new TransformationFailedException($violations[0]->getMessage()); + $exception->setInvalidMessage($violations[0]->getMessage()); + + throw $exception; + } + + if (false === @getimagesize($value->getPathname())) { + $message = 'The uploaded file is not a valid image.'; + $exception = new TransformationFailedException($message); + $exception->setInvalidMessage($message); + + throw $exception; } return $value->guessExtension() . '|' . base64_encode(file_get_contents($value->getPathname()));
src/CoreBundle/Tests/FormTestCase.php+7 −1 modified@@ -121,8 +121,14 @@ protected function getTypeExtensions(): array */ protected function getTypes(): array { + $validator = M::mock(ValidatorInterface::class); + $validator + ->shouldReceive('validate') + ->zeroOrMoreTimes() + ->andReturn(new ConstraintViolationList()); + return [ - 'image_upload' => new ImageUploadType(), + 'image_upload' => new ImageUploadType($validator), ]; }
src/CoreBundle/Tests/Form/Type/ImageUploadTypeTest.php+180 −0 added@@ -0,0 +1,180 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of SolidInvoice project. + * + * (c) Pierre du Plessis <open-source@solidworx.co> + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidInvoice\CoreBundle\Tests\Form\Type; + +use PHPUnit\Framework\TestCase; +use SolidInvoice\CoreBundle\Form\Type\ImageUploadType; +use Symfony\Component\Form\Extension\HttpFoundation\HttpFoundationExtension; +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Form\Forms; +use Symfony\Component\Form\PreloadedExtension; +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\Validator\Validation; +use Symfony\UX\Dropzone\Form\DropzoneType; + +final class ImageUploadTypeTest extends TestCase +{ + private FormFactoryInterface $factory; + + /** + * @var list<string> + */ + private array $tempFiles = []; + + protected function setUp(): void + { + parent::setUp(); + + $validator = Validation::createValidator(); + + $this->factory = Forms::createFormFactoryBuilder() + ->addExtensions([ + new PreloadedExtension( + [ + new ImageUploadType($validator), + new DropzoneType(), + ], + [], + ), + new HttpFoundationExtension(), + ]) + ->getFormFactory(); + } + + protected function tearDown(): void + { + foreach ($this->tempFiles as $file) { + if (file_exists($file)) { + @unlink($file); + } + } + + parent::tearDown(); + } + + public function testSubmitValidPngImageIsTransformedToEncodedValue(): void + { + $form = $this->factory->create(ImageUploadType::class); + + $form->submit($this->createPngUpload('logo.png')); + + self::assertTrue($form->isSynchronized(), (string) $form->getTransformationFailure()?->getMessage()); + + $data = $form->getData(); + self::assertIsString($data); + self::assertStringContainsString('|', $data); + + [$extension, $encoded] = explode('|', $data, 2); + self::assertSame('png', $extension); + self::assertNotEmpty(base64_decode($encoded, true)); + } + + public function testSubmitValidJpegImageIsTransformedToEncodedValue(): void + { + $form = $this->factory->create(ImageUploadType::class); + + $form->submit($this->createJpegUpload('logo.jpg')); + + self::assertTrue($form->isSynchronized(), (string) $form->getTransformationFailure()?->getMessage()); + + [$extension] = explode('|', (string) $form->getData(), 2); + self::assertSame('jpg', $extension); + } + + public function testSubmitSvgWithEmbeddedScriptIsRejected(): void + { + $svg = <<<'SVG' + <?xml version="1.0" encoding="UTF-8"?> + <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"> + <script>alert(document.cookie)</script> + </svg> + SVG; + + $form = $this->factory->create(ImageUploadType::class); + $form->submit($this->createUploadedFile('logo.svg', $svg, 'image/svg+xml')); + + self::assertFalse( + $form->isSynchronized(), + 'SVG uploads must be rejected to prevent stored XSS via embedded scripts.', + ); + } + + public function testSubmitTextFileDisguisedAsImageIsRejected(): void + { + $form = $this->factory->create(ImageUploadType::class); + $form->submit($this->createUploadedFile('logo.png', 'not an image at all', 'image/png')); + + self::assertFalse( + $form->isSynchronized(), + 'Files that do not contain actual image data must be rejected.', + ); + } + + public function testSubmitPhpFileIsRejected(): void + { + $form = $this->factory->create(ImageUploadType::class); + $form->submit($this->createUploadedFile('logo.php', "<?php echo 'pwned'; ?>", 'application/x-php')); + + self::assertFalse($form->isSynchronized()); + } + + public function testSubmittingNullReturnsNull(): void + { + $form = $this->factory->create(ImageUploadType::class); + $form->submit(null); + + self::assertTrue($form->isSynchronized()); + self::assertNull($form->getData()); + } + + private function createPngUpload(string $name): UploadedFile + { + $image = imagecreatetruecolor(2, 2); + self::assertNotFalse($image); + + $path = $this->tempFile('png'); + imagepng($image, $path); + imagedestroy($image); + + return new UploadedFile($path, $name, 'image/png', null, true); + } + + private function createJpegUpload(string $name): UploadedFile + { + $image = imagecreatetruecolor(2, 2); + self::assertNotFalse($image); + + $path = $this->tempFile('jpg'); + imagejpeg($image, $path); + imagedestroy($image); + + return new UploadedFile($path, $name, 'image/jpeg', null, true); + } + + private function createUploadedFile(string $name, string $contents, string $mimeType): UploadedFile + { + $path = $this->tempFile('bin'); + file_put_contents($path, $contents); + + return new UploadedFile($path, $name, $mimeType, null, true); + } + + private function tempFile(string $extension): string + { + $path = tempnam(sys_get_temp_dir(), 'image_upload_test_') . '.' . $extension; + $this->tempFiles[] = $path; + + return $path; + } +}
Vulnerability mechanics
Root cause
"Missing MIME type validation and image content verification in the logo upload form allows arbitrary file types, enabling stored XSS via SVG with embedded JavaScript."
Attack vector
An authenticated administrator uploads a malicious SVG file containing embedded JavaScript via the company logo upload form at Settings → Company → Logo. The file is accepted without any MIME type or content validation [ref_id=1]. The SVG content is base64-encoded and stored in the settings table, then rendered on every page as a `data:image/svg+xml;base64,...` URI inside an `<img>` tag via a Twig function that disables auto-escaping (`is_safe: ['html']`) [ref_id=1]. When any authenticated user visits any page, the browser decodes the SVG and executes the embedded script, enabling session cookie exfiltration and other client-side attacks.
Affected code
The vulnerability resides in `src/CoreBundle/Form/Type/ImageUploadType.php`, where the `reverseTransform` method only checked `$value->isValid()` (a PHP upload error check) and performed no MIME type validation, extension allowlist, or image content verification. The resulting base64-encoded string was stored and later rendered via `src/CoreBundle/Twig/Extension/GlobalExtension.php` using a Twig function declared with `is_safe: ['html']`, which disabled auto-escaping and allowed the SVG's embedded JavaScript to execute in every authenticated user's browser.
What the fix does
The patch modifies `ImageUploadType.php` to inject a `ValidatorInterface` and apply a Symfony `File` constraint that restricts allowed MIME types to `image/jpeg`, `image/png`, `image/gif`, and `image/webp`, explicitly excluding `image/svg+xml` [patch_id=5642283]. It also adds a `getimagesize()` call to reject files that do not contain valid raster image data. These two checks prevent SVG files (and other non-image payloads) from being accepted, encoded, and stored, thereby closing the stored XSS vector.
Preconditions
- authAttacker must have administrator credentials to access the company logo upload settings
- configThe application must be running a version prior to 2.3.17
- inputAttacker must upload an SVG file containing embedded JavaScript via the logo upload form
Generated on Jun 11, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.