From aad8a965ee6fa5a55bc836801facc3432ba80d2b Mon Sep 17 00:00:00 2001 From: addshore Date: Wed, 30 Sep 2026 23:52:27 +0100 Subject: [PATCH] Support SVG wiki logo uploads Accept PNG and SVG logos up to 2 MiB. Sanitise SVGs before storage, check embedded CSS using Wikimedia's CSS parser, and generate PNG logo and favicon fallbacks with rsvg-convert (as svg support is less than png support). Basically all based on mediawikis own handling. Store the vector URL in wwLogoSvg and clear it when a PNG replaces the logo. Add regression coverage and document the processing requirements. The MW change https://github.com/wbstack/mediawiki/pull/569 would be needed ahead of this. Bug: T356389 --- Dockerfile | 2 +- app/Http/Controllers/WikiLogoController.php | 2 +- app/Jobs/SetWikiLogo.php | 53 ++++- app/Services/SvgLogo.php | 118 +++++++++++ app/WikiSetting.php | 2 + composer.json | 4 +- composer.lock | 204 +++++++++++++++++++- tests/Jobs/SetWikiLogoTest.php | 41 ++++ tests/Routes/Wiki/LogoUpdateTest.php | 44 +++++ tests/Services/SvgLogoTest.php | 98 ++++++++++ tests/data/logo.svg | 4 + 11 files changed, 561 insertions(+), 11 deletions(-) create mode 100644 app/Services/SvgLogo.php create mode 100644 tests/Services/SvgLogoTest.php create mode 100644 tests/data/logo.svg diff --git a/Dockerfile b/Dockerfile index fb2a5f679..5c1b0f33e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ FROM php:8.2-apache RUN apt-get update \ # Needed for the imagick php extension install - && apt-get install -y --no-install-recommends libmagickwand-dev libpq-dev mariadb-client \ + && apt-get install -y --no-install-recommends libmagickwand-dev libpq-dev mariadb-client librsvg2-bin \ && echo "" | pecl install imagick redis \ && docker-php-ext-enable imagick \ && docker-php-ext-enable redis \ diff --git a/app/Http/Controllers/WikiLogoController.php b/app/Http/Controllers/WikiLogoController.php index c06f01f3f..169ff9bf8 100644 --- a/app/Http/Controllers/WikiLogoController.php +++ b/app/Http/Controllers/WikiLogoController.php @@ -18,7 +18,7 @@ class WikiLogoController extends Controller { */ public function update(Request $request) { $request->validate([ - 'logo' => 'required|mimes:png', + 'logo' => 'required|file|mimes:png,svg|max:2048', ]); $wiki = $request->attributes->get('wiki'); diff --git a/app/Jobs/SetWikiLogo.php b/app/Jobs/SetWikiLogo.php index 2aa4b0295..623d77ba3 100644 --- a/app/Jobs/SetWikiLogo.php +++ b/app/Jobs/SetWikiLogo.php @@ -2,12 +2,14 @@ namespace App\Jobs; +use App\Services\SvgLogo; use App\Wiki; use App\WikiSetting; use Illuminate\Database\QueryException; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Http\File; use Illuminate\Support\Facades\Storage; +use Illuminate\Validation\ValidationException; use Intervention\Image\Facades\Image; /** @@ -68,30 +70,58 @@ public function handle(): void { // Get the directory for storing this site's logos $logosDir = Wiki::getLogosDirectory($wiki->id); + $file = new File($this->logoPath); + $isSvg = $file->getMimeType() === 'image/svg+xml'; + $svg = null; + if ($isSvg) { + $svgLogo = new SvgLogo(); + $svg = $svgLogo->sanitize(file_get_contents($this->logoPath)); + $image = Image::canvas(135, 135)->insert(Image::make($svgLogo->rasterize($svg)), 'center'); + } else { + if ($file->getMimeType() !== 'image/png') { + throw ValidationException::withMessages(['logo' => 'The logo must be a PNG or SVG image.']); + } + $image = Image::make($this->logoPath)->resize(135, 135); + } + // Upload the local image to the cloud storage - $storage->putFileAs($logosDir, new File($this->logoPath), 'raw.png', ['visibility' => 'public']); + if ($svg !== null) { + $stored = $storage->put($logosDir . '/logo.svg', $svg, [ + 'visibility' => 'public', + 'ContentType' => 'image/svg+xml', + ]); + } else { + $stored = $storage->putFileAs($logosDir, $file, 'raw.png', ['visibility' => 'public']); + } + if (!$stored) { + throw new \RuntimeException('Failed to store the wiki logo.'); + } // Store a conversion for the actual site logo $reducedPath = $logosDir . '/135.png'; if ($storage->exists($reducedPath)) { $storage->delete($reducedPath); } - $storage->writeStream( + if (!$storage->writeStream( $reducedPath, - Image::make($this->logoPath)->resize(135, 135)->stream()->detach(), + $image->stream('png')->detach(), ['visibility' => 'public'], - ); + )) { + throw new \RuntimeException('Failed to store the PNG wiki logo.'); + } // Store a conversion for the favicon $faviconPath = $logosDir . '/64.ico'; if ($storage->exists($faviconPath)) { $storage->delete($faviconPath); } - $storage->writeStream( + if (!$storage->writeStream( $faviconPath, - Image::make($this->logoPath)->resize(64, 64)->stream()->detach(), + ($isSvg ? $image : Image::make($this->logoPath))->resize(64, 64)->stream('png')->detach(), ['visibility' => 'public'], - ); + )) { + throw new \RuntimeException('Failed to store the wiki favicon.'); + } // Get the urls $logoUrl = $storage->url($reducedPath); @@ -111,5 +141,14 @@ public function handle(): void { ['wiki_id' => $wiki->id, 'name' => WikiSetting::wgFavicon], ['value' => $faviconUrl] ); + + if ($svg !== null) { + WikiSetting::updateOrCreate( + ['wiki_id' => $wiki->id, 'name' => WikiSetting::wwLogoSvg], + ['value' => $storage->url($logosDir . '/logo.svg') . '?u=' . time()] + ); + } else { + $wiki->settings()->where('name', WikiSetting::wwLogoSvg)->delete(); + } } } diff --git a/app/Services/SvgLogo.php b/app/Services/SvgLogo.php new file mode 100644 index 000000000..9cfb8ec98 --- /dev/null +++ b/app/Services/SvgLogo.php @@ -0,0 +1,118 @@ +parseSvg($contents); + $sanitizer = new Sanitizer(); + $sanitizer->removeRemoteReferences(true); + $sanitized = $sanitizer->sanitize($contents); + if (!$sanitized) { + throw ValidationException::withMessages(['logo' => 'The SVG logo must be a valid SVG document.']); + } + + $document = $this->parseSvg($sanitized); + + foreach ($document->getElementsByTagName('*') as $element) { + if (!$element instanceof DOMElement) { + continue; + } + if ($element->localName === 'style') { + $this->checkCss($element->textContent); + } + foreach ($element->attributes as $attribute) { + if ($attribute->localName === 'href' && $attribute->value !== '' && + !str_starts_with($attribute->value, '#') && + !preg_match('~^data:image/(png|jpeg|gif);base64,~i', $attribute->value)) { + throw ValidationException::withMessages(['logo' => 'SVG logos must not reference external resources.']); + } + if (in_array($attribute->localName, [ + 'style', 'font', 'clip-path', 'fill', 'filter', 'marker', + 'marker-end', 'marker-mid', 'marker-start', 'mask', 'stroke', 'cursor', + ], true)) { + $this->checkCss($attribute->value); + } + } + } + + return $sanitized; + } + + private function parseSvg(string $contents): DOMDocument { + $document = new DOMDocument(); + $loaded = $contents !== '' && $document->loadXML($contents, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); + $root = $document->documentElement; + if (!$loaded || $root === null || $root->localName !== 'svg' || $root->namespaceURI !== 'http://www.w3.org/2000/svg') { + throw ValidationException::withMessages(['logo' => 'The SVG logo must be a valid SVG document.']); + } + + return $document; + } + + private function checkCss(string $css): void { + // Like MediaWiki's SVGCSSChecker, inspect parsed tokens, not URL-matching regexes. + if (preg_match('/[\x00-\x08\x0b\x0e-\x1f\x7f]/', $css)) { + throw ValidationException::withMessages(['logo' => 'The SVG logo contains invalid CSS.']); + } + $parser = Parser::newFromString($css); + $tokens = $parser->parseComponentValueList()->toTokenArray(); + if ($parser->getParseErrors()) { + throw ValidationException::withMessages(['logo' => 'The SVG logo contains invalid CSS.']); + } + foreach ($tokens as $index => $token) { + $type = $token->type(); + $value = strtolower((string) $token->value()); + if ($type === Token::T_URL && str_starts_with($value, '#')) { + continue; + } + if ($type === Token::T_FUNCTION && $value === 'url') { + $next = $index + 1; + while (isset($tokens[$next]) && $tokens[$next]->type() === Token::T_WHITESPACE) { + $next++; + } + if (isset($tokens[$next]) && $tokens[$next]->type() === Token::T_STRING && + str_starts_with($tokens[$next]->value(), '#')) { + continue; + } + } elseif (!in_array($type, [Token::T_URL, Token::T_BAD_URL], true) && + !($type === Token::T_AT_KEYWORD && in_array($value, ['import', 'charset'], true)) && + !($type === Token::T_FUNCTION && in_array($value, [ + 'src', 'image', 'image-set', '-webkit-image-set', 'expression', + ], true))) { + continue; + } + throw ValidationException::withMessages(['logo' => 'SVG logos must not reference external resources.']); + } + } + + public function rasterize(string $sanitized): string { + // Standard input leaves librsvg without a base URL, disabling external file access. + $process = new Process([ + 'rsvg-convert', '--format=png', '--width=135', '--height=135', '--keep-aspect-ratio', + ]); + $process->setInput($sanitized); + $process->setTimeout(10); + try { + $process->mustRun(); + } catch (ProcessFailedException $e) { + if ($process->getExitCode() !== 1) { + throw $e; + } + throw ValidationException::withMessages([ + 'logo' => 'The SVG logo could not be rendered. Please upload a self-contained SVG with a valid viewBox or dimensions.', + ]); + } + + return $process->getOutput(); + } +} diff --git a/app/WikiSetting.php b/app/WikiSetting.php index 9e56e5688..77f5e7c2a 100644 --- a/app/WikiSetting.php +++ b/app/WikiSetting.php @@ -42,6 +42,8 @@ class WikiSetting extends Model { public const wgLogo = 'wgLogo'; + public const wwLogoSvg = 'wwLogoSvg'; + public const wgFavicon = 'wgFavicon'; public const wgOAuth2PrivateKey = 'wgOAuth2PrivateKey'; diff --git a/composer.json b/composer.json index 369437582..928b130c1 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "type": "project", "require": { "absszero/laravel-stackdriver-error-reporting": "^1.9", + "enshrined/svg-sanitize": "^1.0", "firebase/php-jwt": "^7.0", "google/recaptcha": "^1.2", "guzzlehttp/guzzle": "^7.13", @@ -26,7 +27,8 @@ "maclof/kubernetes-client": "^0.31.0", "mxl/laravel-job": "^1.5", "php-http/guzzle7-adapter": "^1.0", - "predis/predis": "^3.4" + "predis/predis": "^3.4", + "wikimedia/css-sanitizer": "^6.2.1" }, "require-dev": { "barryvdh/laravel-ide-helper": "^3", diff --git a/composer.lock b/composer.lock index 400f554b1..0db55221a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4f87450f1479cff4b4f12411ac2cb301", + "content-hash": "2fdd9eada8e86b9ce2aaabd76574380a", "packages": [ { "name": "absszero/laravel-stackdriver-error-reporting", @@ -849,6 +849,51 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "enshrined/svg-sanitize", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/darylldoyle/svg-sanitizer.git", + "reference": "f3300fcd1bbf67d205b52217c75d0f7d6a8c47ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/darylldoyle/svg-sanitizer/zipball/f3300fcd1bbf67d205b52217c75d0f7d6a8c47ff", + "reference": "f3300fcd1bbf67d205b52217c75d0f7d6a8c47ff", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.5 || ^8.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "enshrined\\svgSanitize\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Daryll Doyle", + "email": "daryll@enshrined.co.uk" + } + ], + "description": "An SVG sanitizer for PHP", + "support": { + "issues": "https://github.com/darylldoyle/svg-sanitizer/issues", + "source": "https://github.com/darylldoyle/svg-sanitizer/tree/1.0.0" + }, + "time": "2026-09-01T09:35:47+00:00" + }, { "name": "evenement/evenement", "version": "v3.0.2", @@ -9844,6 +9889,163 @@ } ], "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "wikimedia/css-sanitizer", + "version": "v6.2.1", + "source": { + "type": "git", + "url": "https://github.com/wikimedia/css-sanitizer.git", + "reference": "60973b8355c72a4acdddca3dcaa93bc901e0ccf2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wikimedia/css-sanitizer/zipball/60973b8355c72a4acdddca3dcaa93bc901e0ccf2", + "reference": "60973b8355c72a4acdddca3dcaa93bc901e0ccf2", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "wikimedia/scoped-callback": "3.0.0 || 4.0.0 || 5.0.0", + "wikimedia/utfnormal": "^3.0.1 || ^4.0.0" + }, + "require-dev": { + "mediawiki/mediawiki-codesniffer": "50.0.0", + "mediawiki/mediawiki-phan-config": "0.19.0", + "mediawiki/minus-x": "2.0.1", + "php-parallel-lint/php-console-highlighter": "1.0.0", + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpunit/phpunit": "10.5.63", + "wikimedia/testing-access-wrapper": "^4.0.0", + "wikimedia/update-history": "^1.0.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Wikimedia\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Brad Jorsch", + "email": "bjorsch@wikimedia.org" + } + ], + "description": "Classes to parse and sanitize CSS", + "homepage": "https://www.mediawiki.org/wiki/Css-sanitizer", + "support": { + "source": "https://github.com/wikimedia/css-sanitizer/tree/v6.2.1" + }, + "time": "2026-03-04T17:00:28+00:00" + }, + { + "name": "wikimedia/scoped-callback", + "version": "v5.0.0", + "source": { + "type": "git", + "url": "https://github.com/wikimedia/mediawiki-libs-ScopedCallback.git", + "reference": "7fa061561a70c49e5e5e6d40dd8f7c151affa0f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wikimedia/mediawiki-libs-ScopedCallback/zipball/7fa061561a70c49e5e5e6d40dd8f7c151affa0f3", + "reference": "7fa061561a70c49e5e5e6d40dd8f7c151affa0f3", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "mediawiki/mediawiki-codesniffer": "44.0.0", + "mediawiki/mediawiki-phan-config": "0.14.0", + "mediawiki/minus-x": "1.1.3", + "ockcyp/covers-validator": "1.6.0", + "php-parallel-lint/php-console-highlighter": "1.0.0", + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpunit/phpunit": "9.6.16" + }, + "type": "library", + "autoload": { + "psr-4": { + "Wikimedia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Aaron Schulz", + "email": "aschulz@wikimedia.org" + } + ], + "description": "Make a callback run when a dummy object leaves the scope.", + "homepage": "https://www.mediawiki.org/wiki/ScopedCallback", + "support": { + "source": "https://github.com/wikimedia/mediawiki-libs-ScopedCallback/tree/v5.0.0" + }, + "time": "2024-10-29T19:36:38+00:00" + }, + { + "name": "wikimedia/utfnormal", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/wikimedia/utfnormal.git", + "reference": "6f3690d3ca446d9745fa3410bb993470afd7bc25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wikimedia/utfnormal/zipball/6f3690d3ca446d9745fa3410bb993470afd7bc25", + "reference": "6f3690d3ca446d9745fa3410bb993470afd7bc25", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=7.4.3" + }, + "require-dev": { + "ext-curl": "*", + "mediawiki/mediawiki-codesniffer": "41.0.0", + "mediawiki/mediawiki-phan-config": "0.12.1", + "mediawiki/minus-x": "1.1.1", + "ockcyp/covers-validator": "1.6.0", + "php-parallel-lint/php-console-highlighter": "1.0.0", + "php-parallel-lint/php-parallel-lint": "1.3.2", + "phpunit/phpunit": "9.5.28" + }, + "suggest": { + "ext-intl": "Optional extension, for improved performance. PHP code fallback is used instead if ext-intl is not present." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Brion Vibber", + "email": "bvibber@wikimedia.org" + } + ], + "description": "Contains Unicode normalization routines, including both pure PHP implementations and automatic use of the 'intl' PHP extension when present", + "homepage": "https://www.mediawiki.org/wiki/utfnormal", + "support": { + "source": "https://github.com/wikimedia/utfnormal/tree/4.0.0" + }, + "time": "2023-04-17T20:37:19+00:00" } ], "packages-dev": [ diff --git a/tests/Jobs/SetWikiLogoTest.php b/tests/Jobs/SetWikiLogoTest.php index 520052a1f..e161eb086 100644 --- a/tests/Jobs/SetWikiLogoTest.php +++ b/tests/Jobs/SetWikiLogoTest.php @@ -9,6 +9,7 @@ use App\WikiSetting; use ErrorException; use Illuminate\Contracts\Queue\Job; +use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\Storage; @@ -108,6 +109,46 @@ public static function validProvider() { yield ['domain', 'example.test.dev', __DIR__ . '/../data/logo_200x200.png']; } + public function testSvgLogoHasVectorAndRasterOutputsAndCanBeReplacedWithPng(): void { + $wiki = Wiki::factory('nodb')->create(); + $storage = Storage::fake('static-assets'); + $directory = Wiki::getLogosDirectory($wiki->id); + + $this->assertJobSucceeds('id', $wiki->id, __DIR__ . '/../data/logo.svg'); + + $storage->assertExists([$directory . '/logo.svg', $directory . '/135.png', $directory . '/64.ico']); + $storage->assertMissing($directory . '/raw.svg'); + $this->assertSame('public', $storage->getVisibility($directory . '/logo.svg')); + $this->assertStringContainsString('get($directory . '/logo.svg')); + $this->assertStringContainsString('/logo.svg?u=', $wiki->settings()->firstWhere('name', WikiSetting::wwLogoSvg)->value); + foreach (['135.png' => 135, '64.ico' => 64] as $file => $size) { + $image = Image::make($storage->path($directory . '/' . $file)); + $this->assertSame($size, $image->width()); + $this->assertSame($size, $image->height()); + $this->assertSame('image/png', $image->mime()); + } + + $this->assertJobSucceeds('id', $wiki->id, __DIR__ . '/../data/logo_200x200.png'); + $this->assertNull($wiki->settings()->firstWhere('name', WikiSetting::wwLogoSvg)); + $this->assertStringContainsString('/135.png?u=', $wiki->settings()->firstWhere('name', WikiSetting::wgLogo)->value); + } + + public function testSvgStorageFailureDoesNotUpdateSettings(): void { + $wiki = Wiki::factory('nodb')->create(); + $storage = $this->createMock(FilesystemAdapter::class); + $storage->expects($this->once())->method('put')->willReturn(false); + Storage::shouldReceive('disk')->with('static-assets')->andReturn($storage); + + try { + (new SetWikiLogo('id', $wiki->id, __DIR__ . '/../data/logo.svg'))->handle(); + $this->fail('A failed upload must not report success.'); + } catch (\RuntimeException $e) { + $this->assertSame('Failed to store the wiki logo.', $e->getMessage()); + $this->assertNull($wiki->settings()->firstWhere('name', WikiSetting::wwLogoSvg)); + $this->assertNull($wiki->settings()->firstWhere('name', WikiSetting::wgLogo)); + } + } + public static function invalidProvider() { // $wikiKey, $wikiValue, $logoPath yield "id doesn't exist" => ['id', 999, __DIR__ . '/../data/logo_200x200.png']; diff --git a/tests/Routes/Wiki/LogoUpdateTest.php b/tests/Routes/Wiki/LogoUpdateTest.php index d23c2ef4e..2b2ed5b87 100644 --- a/tests/Routes/Wiki/LogoUpdateTest.php +++ b/tests/Routes/Wiki/LogoUpdateTest.php @@ -7,14 +7,58 @@ use App\WikiManager; use App\WikiSetting; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Intervention\Image\Facades\Image; use Tests\TestCase; class LogoUpdateTest extends TestCase { + use DatabaseTransactions; use HasFactory; + public function testSvgUpload(): void { + $storage = Storage::fake('static-assets'); + $user = User::factory()->create(['verified' => true]); + $wiki = Wiki::factory('nodb')->create(); + WikiManager::factory()->create(['wiki_id' => $wiki->id, 'user_id' => $user->id]); + $file = UploadedFile::fake()->createWithContent('logo.svg', file_get_contents(__DIR__ . '/../../data/logo.svg')); + + $this->actingAs($user, 'api') + ->postJson('wiki/logo/update', ['wiki' => $wiki->id, 'logo' => $file]) + ->assertOk() + ->assertJson(['success' => true, 'url' => $wiki->settings()->firstWhere('name', WikiSetting::wgLogo)->value]); + + $this->assertStringContainsString('/logo.svg?u=', $wiki->settings()->firstWhere('name', WikiSetting::wwLogoSvg)->value); + } + + /** + * @dataProvider invalidLogoProvider + */ + public function testInvalidUploadDoesNotChangeExistingLogo(string $name, string $contents): void { + $storage = Storage::fake('static-assets'); + $user = User::factory()->create(['verified' => true]); + $wiki = Wiki::factory('nodb')->create(); + WikiManager::factory()->create(['wiki_id' => $wiki->id, 'user_id' => $user->id]); + WikiSetting::create(['wiki_id' => $wiki->id, 'name' => WikiSetting::wgLogo, 'value' => 'existing.png']); + $file = UploadedFile::fake()->createWithContent($name, $contents); + + $this->actingAs($user, 'api') + ->postJson('wiki/logo/update', ['wiki' => $wiki->id, 'logo' => $file]) + ->assertUnprocessable() + ->assertJsonValidationErrors('logo'); + $this->assertSame('existing.png', $wiki->settings()->firstWhere('name', WikiSetting::wgLogo)->value); + $this->assertSame([], $storage->allFiles()); + } + + public static function invalidLogoProvider(): iterable { + yield 'wrong format' => ['logo.svg', 'not SVG']; + yield 'malformed SVG' => ['logo.svg', '']; + yield 'no drawable size' => ['logo.svg', '']; + yield 'oversized file' => ['logo.svg', '']; + yield 'external reference' => ['logo.svg', '']; + } + public function testUpdate() { $storage = Storage::fake('static-assets'); $file = UploadedFile::fake()->createWithContent('logo_200x200.png', file_get_contents(__DIR__ . '/../../data/logo_200x200.png')); diff --git a/tests/Services/SvgLogoTest.php b/tests/Services/SvgLogoTest.php new file mode 100644 index 000000000..cd5aed905 --- /dev/null +++ b/tests/Services/SvgLogoTest.php @@ -0,0 +1,98 @@ +sanitize( + '' . + '
unsafe
' . + '
' + ); + $this->assertStringNotContainsString('onload', $svg); + $this->assertStringNotContainsString('script', $svg); + $this->assertStringNotContainsString('foreignObject', $svg); + $image = Image::make($service->rasterize($svg)); + $this->assertSame(135, $image->width()); + $this->assertSame(135, $image->height()); + $this->assertSame('#336699', $image->pickColor(60, 60, 'hex')); + } + + public function testPreservesInternalGradientReferencesAndCss(): void { + $svg = '' . + '' . + '' . + '' . + ''; + $service = new SvgLogo(); + $sanitized = $service->sanitize($svg); + $this->assertStringContainsString('linearGradient', $sanitized); + $this->assertStringContainsString('url("#g")', $sanitized); + $this->assertSame(135, Image::make($service->rasterize($sanitized))->width()); + } + + public function testSanitizerRemovesUnsafeImageReferences(): void { + $svg = (new SvgLogo())->sanitize( + '' . + '' . + '' + ); + $this->assertStringNotContainsString('relative.png', $svg); + $this->assertStringNotContainsString('example.test', $svg); + $this->assertStringNotContainsString('base64', $svg); + } + + public function testRasterizerPreservesAspectRatioAndBoundsHugeDimensions(): void { + $service = new SvgLogo(); + $svg = $service->sanitize( + '' . + '' + ); + $image = Image::make($service->rasterize($svg)); + $this->assertSame(135, $image->width()); + $this->assertSame(68, $image->height()); + } + + /** + * @dataProvider invalidSvgProvider + */ + public function testRejectsInvalidOrExternalResources(string $svg): void { + $this->expectException(ValidationException::class); + (new SvgLogo())->sanitize($svg); + } + + public static function invalidSvgProvider(): iterable { + yield 'malformed XML' => ['']; + yield 'not an SVG' => ['not an image']; + yield 'wrong namespace' => ['']; + yield 'empty file' => ['']; + yield 'external entity' => [']>' . + '&x;']; + foreach ([ + '', + '', + '', + '', + '', + '', + '', + '', + '', + ] as $contents) { + yield $contents => ['' . $contents . '']; + } + } + + public function testRejectsUnrenderableSvg(): void { + $service = new SvgLogo(); + $svg = $service->sanitize(''); + $this->expectException(ValidationException::class); + $service->rasterize($svg); + } +} diff --git a/tests/data/logo.svg b/tests/data/logo.svg new file mode 100644 index 000000000..0d8d7ec1b --- /dev/null +++ b/tests/data/logo.svg @@ -0,0 +1,4 @@ + + + +