diff --git a/app/Providers/TwoFAuthServiceProvider.php b/app/Providers/TwoFAuthServiceProvider.php
index 4792bdfd..b6ca21e6 100644
--- a/app/Providers/TwoFAuthServiceProvider.php
+++ b/app/Providers/TwoFAuthServiceProvider.php
@@ -9,6 +9,7 @@
use App\Services\ReleaseRadarService;
use App\Services\SettingService;
use App\Services\TwoFAccountService;
+use enshrined\svgSanitize\Sanitizer;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
use Zxing\QrReader;
@@ -30,8 +31,8 @@ public function register()
return new SettingService;
});
- $this->app->singleton(IconStoreService::class, function () {
- return new IconStoreService;
+ $this->app->singleton(IconStoreService::class, function ($app) {
+ return new IconStoreService($app->make(Sanitizer::class));
});
$this->app->singleton(LogoService::class, function ($app) {
diff --git a/app/Services/IconStoreService.php b/app/Services/IconStoreService.php
index a36d7380..64b888ca 100644
--- a/app/Services/IconStoreService.php
+++ b/app/Services/IconStoreService.php
@@ -6,6 +6,7 @@
use App\Facades\Settings;
use App\Models\Icon;
use App\Models\TwoFAccount;
+use enshrined\svgSanitize\Sanitizer;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
@@ -26,13 +27,21 @@ class IconStoreService
*/
protected bool $usesDatabase;
+ /**
+ * The SVG sanitizer
+ */
+ protected Sanitizer $svgSanitizer;
+
/**
*
*/
- public function __construct()
+ public function __construct(Sanitizer $svgSanitizer)
{
$this->usesDatabase = Settings::get('storeIconsInDatabase');
$this->setDisk();
+
+ $this->svgSanitizer = $svgSanitizer;
+ $this->svgSanitizer->removeRemoteReferences(true);
}
/**
@@ -207,6 +216,21 @@ public function store(string $name, string $content) : bool
{
$storedToDisk = $this->storeToDisk($name, $content);
+ if ($this->mimeType($name) == 'image/svg+xml') {
+ $sanitized = $this->sanitize($content);
+
+ if (! $sanitized) {
+ $this->delete($name);
+
+ return false;
+ }
+
+ if ($content != $sanitized) {
+ $content = $sanitized;
+ $storedToDisk = $this->storeToDisk($name, $content);
+ }
+ }
+
if ($this->usesDatabase) {
return $this->storeToDatabase($name, $content);
}
@@ -214,6 +238,14 @@ public function store(string $name, string $content) : bool
return $storedToDisk;
}
+ /**
+ * Sanitize the given content (when icon is an svg image)
+ */
+ protected function sanitize(string $content) : string
+ {
+ return $this->svgSanitizer->sanitize($content);
+ }
+
/**
* Create the given icon in the disk
*/
diff --git a/composer.json b/composer.json
index e2354fa6..7d238369 100644
--- a/composer.json
+++ b/composer.json
@@ -28,6 +28,7 @@
"ext-xml": "*",
"chillerlan/php-qrcode": "^5.0",
"doctormckay/steam-totp": "^1.0",
+ "enshrined/svg-sanitize": "^0.20.0",
"google/protobuf": "^4.26",
"jackiedo/dotenv-editor": "dev-master",
"jenssegers/agent": "^2.6",
diff --git a/composer.lock b/composer.lock
index b71c3778..cb9123a3 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": "bff445ed39877e4dfccfb5b451e0d96a",
+ "content-hash": "da7b7e586a5f017685b05bf7189d8c5e",
"packages": [
{
"name": "brick/math",
@@ -770,6 +770,51 @@
],
"time": "2023-10-06T06:47:41+00:00"
},
+ {
+ "name": "enshrined/svg-sanitize",
+ "version": "0.20.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/darylldoyle/svg-sanitizer.git",
+ "reference": "068d9fcf912c88a0471d101d95a2caa87c50aee7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/darylldoyle/svg-sanitizer/zipball/068d9fcf912c88a0471d101d95a2caa87c50aee7",
+ "reference": "068d9fcf912c88a0471d101d95a2caa87c50aee7",
+ "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/0.20.0"
+ },
+ "time": "2024-09-05T10:18:12+00:00"
+ },
{
"name": "firebase/php-jwt",
"version": "v6.10.1",
diff --git a/tests/Api/v1/Controllers/IconControllerTest.php b/tests/Api/v1/Controllers/IconControllerTest.php
index e9bba6af..b0e1188f 100644
--- a/tests/Api/v1/Controllers/IconControllerTest.php
+++ b/tests/Api/v1/Controllers/IconControllerTest.php
@@ -13,6 +13,7 @@
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
+use Tests\Classes\LocalFile;
use Tests\Data\HttpRequestTestData;
use Tests\Data\OtpTestData;
use Tests\FeatureTestCase;
@@ -41,7 +42,7 @@ public function setUp() : void
LogoService::TFA_URL => Http::response(HttpRequestTestData::TFA_JSON_BODY, 200),
]);
Http::fake([
- OtpTestData::EXTERNAL_IMAGE_URL_DECODED => Http::response((new FileFactory)->image('file.png', 10, 10)->tempFile, 200),
+ OtpTestData::EXTERNAL_IMAGE_URL_DECODED => Http::response((new FileFactory)->image('file.png', 10, 10)->tempFile, 200),
]);
$this->user = User::factory()->create();
@@ -84,6 +85,21 @@ public function test_upload_with_invalid_data_returns_validation_error()
->assertStatus(422);
}
+ #[Test]
+ public function test_upload_infected_svg_data_stores_stores_sanitized_svg_content()
+ {
+ $file = LocalFile::fake()->infectedSvgIconFile();
+
+ $response = $this->actingAs($this->user, 'api-guard')
+ ->json('POST', '/api/v1/icons', [
+ 'icon' => $file,
+ ])
+ ->assertCreated();
+
+ $svgContent = IconStore::get($response->getData()->filename);
+ $this->assertStringNotContainsString(OtpTestData::ICON_SVG_MALICIOUS_CODE, $svgContent);
+ }
+
#[Test]
public function test_fetch_logo_returns_filename()
{
@@ -97,6 +113,22 @@ public function test_fetch_logo_returns_filename()
]);
}
+ #[Test]
+ public function test_fetch_logo_with_infected_svg_data_stores_sanitized_svg_content()
+ {
+ $response = $this->actingAs($this->user, 'api-guard')
+ ->json('POST', '/api/v1/icons/default', [
+ 'service' => 'service',
+ ])
+ ->assertStatus(201)
+ ->assertJsonStructure([
+ 'filename',
+ ]);
+
+ $svgContent = IconStore::get($response->getData()->filename);
+ $this->assertStringNotContainsString(OtpTestData::ICON_SVG_MALICIOUS_CODE, $svgContent);
+ }
+
#[Test]
public function test_fetch_unknown_logo_returns_nothing()
{
diff --git a/tests/Api/v1/Controllers/TwoFAccountControllerTest.php b/tests/Api/v1/Controllers/TwoFAccountControllerTest.php
index 0f0a43e5..39a93edd 100644
--- a/tests/Api/v1/Controllers/TwoFAccountControllerTest.php
+++ b/tests/Api/v1/Controllers/TwoFAccountControllerTest.php
@@ -8,6 +8,7 @@
use App\Api\v1\Resources\TwoFAccountExportResource;
use App\Api\v1\Resources\TwoFAccountReadResource;
use App\Api\v1\Resources\TwoFAccountStoreResource;
+use App\Facades\IconStore;
use App\Facades\Settings;
use App\Models\Group;
use App\Models\TwoFAccount;
@@ -242,11 +243,8 @@ public function setUp() : void
Http::fake([
LogoService::TFA_IMG_URL . '*' => Http::response(HttpRequestTestData::SVG_LOGO_BODY, 200),
LogoService::TFA_URL => Http::response(HttpRequestTestData::TFA_JSON_BODY, 200),
- ]);
- Http::fake([
OtpTestData::EXTERNAL_IMAGE_URL_DECODED => Http::response((new FileFactory)->image('file.png', 10, 10)->tempFile, 200),
- ]);
- Http::fake([
+ OtpTestData::EXTERNAL_INFECTED_IMAGE_URL_DECODED => Http::response((new FileFactory)->createWithContent('infected.svg', OtpTestData::ICON_SVG_DATA_INFECTED)->tempFile, 200),
'example.com/*' => Http::response(null, 400),
]);
@@ -1218,6 +1216,21 @@ public function test_preview_with_unreachable_image_returns_success_with_no_icon
]);
}
+ #[Test]
+ public function test_preview_with_infected_svg_image_stores_sanitized_image()
+ {
+ $this->user['preferences->getOfficialIcons'] = true;
+
+ $response = $this->actingAs($this->user, 'api-guard')
+ ->json('POST', '/api/v1/twofaccounts/preview', [
+ 'uri' => OtpTestData::TOTP_URI_WITH_INFECTED_SVG_IMAGE,
+ ])
+ ->assertOk();
+
+ $svgContent = IconStore::get($response->getData()->icon);
+ $this->assertStringNotContainsString(OtpTestData::ICON_SVG_MALICIOUS_CODE, $svgContent);
+ }
+
#[Test]
public function test_export_returns_json_migration_resource()
{
diff --git a/tests/Classes/LocalFileFactory.php b/tests/Classes/LocalFileFactory.php
index fc257b9c..67cd8a89 100644
--- a/tests/Classes/LocalFileFactory.php
+++ b/tests/Classes/LocalFileFactory.php
@@ -215,4 +215,20 @@ public function invalidPlainTextFileEmpty()
fwrite($temp, ob_get_clean());
}));
}
+
+ /**
+ * Create a new local infected SVG file.
+ *
+ * @return \Illuminate\Http\Testing\File
+ */
+ public function infectedSvgIconFile()
+ {
+ return new File('infectedSvgIcon.svg', tap(tmpfile(), function ($temp) {
+ ob_start();
+
+ echo OtpTestData::ICON_SVG_DATA_INFECTED;
+
+ fwrite($temp, ob_get_clean());
+ }));
+ }
}
diff --git a/tests/Data/OtpTestData.php b/tests/Data/OtpTestData.php
index f0f25d6a..81c83fce 100644
--- a/tests/Data/OtpTestData.php
+++ b/tests/Data/OtpTestData.php
@@ -36,6 +36,10 @@ class OtpTestData
const EXTERNAL_IMAGE_URL_ENCODED = 'https%3A%2F%2Fen.opensuse.org%2Fimages%2F4%2F44%2FButton-filled-colour.png';
+ const EXTERNAL_INFECTED_IMAGE_URL_DECODED = 'https://image.com/infected.svg';
+
+ const EXTERNAL_INFECTED_IMAGE_URL_ENCODED = 'https%3A%2F%2Fimage.com%2Finfected.svg';
+
const ICON_PNG = 'test.png';
const ICON_PNG_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAC0lEQVQImWP4DwQACfsD/eNV8pwAAAAASUVORK5CYII=';
@@ -58,6 +62,10 @@ class OtpTestData
const ICON_SVG_DATA_ENCODED = 'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiPg0KICAgPGNpcmNsZSBjeD0iNTEyIiBjeT0iNTEyIiByPSI1MTIiIHN0eWxlPSJmaWxsOiMwMDBlOWMiLz4NCiAgIDxwYXRoIGQ9Im03MDAuMiA0NjYuNSA2MS4yLTEwNi4zYzIzLjYgNDEuNiAzNy4yIDg5LjggMzcuMiAxNDEuMSAwIDY4LjgtMjQuMyAxMzEuOS02NC43IDE4MS40SDU3NS44bDQ4LjctODQuNmgtNjQuNGw3NS44LTEzMS43IDY0LjMuMXptLTU1LjQtMTI1LjJMNDQ4LjMgNjgyLjVsLjEuMkgyOTAuMWMtNDAuNS00OS41LTY0LjctMTEyLjYtNjQuNy0xODEuNCAwLTUxLjQgMTMuNi05OS42IDM3LjMtMTQxLjNsMTAyLjUgMTc4LjIgMTEzLjMtMTk3aDE2Ni4zeiIgc3R5bGU9ImZpbGw6I2ZmZiIvPg0KPC9zdmc+DQo=';
+ const ICON_SVG_MALICIOUS_CODE = '';
+
+ const ICON_SVG_DATA_INFECTED = '';
+
const ICON_GIF = 'test.gif';
const ICON_GIF_DATA = 'R0lGODlhAQACAPcAAAAAAAAAMwAAZgAAmQAAzAAA/wArAAArMwArZgArmQArzAAr/wBVAABVMwBVZgBVmQBVzABV/wCAAACAMwCAZgCAmQCAzACA/wCqAACqMwCqZgCqmQCqzACq/wDVAADVMwDVZgDVmQDVzADV/wD/AAD/MwD/ZgD/mQD/zAD//zMAADMAMzMAZjMAmTMAzDMA/zMrADMrMzMrZjMrmTMrzDMr/zNVADNVMzNVZjNVmTNVzDNV/zOAADOAMzOAZjOAmTOAzDOA/zOqADOqMzOqZjOqmTOqzDOq/zPVADPVMzPVZjPVmTPVzDPV/zP/ADP/MzP/ZjP/mTP/zDP//2YAAGYAM2YAZmYAmWYAzGYA/2YrAGYrM2YrZmYrmWYrzGYr/2ZVAGZVM2ZVZmZVmWZVzGZV/2aAAGaAM2aAZmaAmWaAzGaA/2aqAGaqM2aqZmaqmWaqzGaq/2bVAGbVM2bVZmbVmWbVzGbV/2b/AGb/M2b/Zmb/mWb/zGb//5kAAJkAM5kAZpkAmZkAzJkA/5krAJkrM5krZpkrmZkrzJkr/5lVAJlVM5lVZplVmZlVzJlV/5mAAJmAM5mAZpmAmZmAzJmA/5mqAJmqM5mqZpmqmZmqzJmq/5nVAJnVM5nVZpnVmZnVzJnV/5n/AJn/M5n/Zpn/mZn/zJn//8wAAMwAM8wAZswAmcwAzMwA/8wrAMwrM8wrZswrmcwrzMwr/8xVAMxVM8xVZsxVmcxVzMxV/8yAAMyAM8yAZsyAmcyAzMyA/8yqAMyqM8yqZsyqmcyqzMyq/8zVAMzVM8zVZszVmczVzMzV/8z/AMz/M8z/Zsz/mcz/zMz///8AAP8AM/8AZv8Amf8AzP8A//8rAP8rM/8rZv8rmf8rzP8r//9VAP9VM/9VZv9Vmf9VzP9V//+AAP+AM/+AZv+Amf+AzP+A//+qAP+qM/+qZv+qmf+qzP+q///VAP/VM//VZv/Vmf/VzP/V////AP//M///Zv//mf//zP///wAAAAAAAAAAAAAAACH5BAEAAPwALAAAAAABAAIAAAgFAPftCwgAOw==';
@@ -86,6 +94,8 @@ class OtpTestData
const TOTP_URI_WITH_UNREACHABLE_IMAGE = 'otpauth://totp/service:account?secret=A4GRFHVVRBGY7UIW&image=' . self::UNREACHABLE_IMAGE_URL;
+ const TOTP_URI_WITH_INFECTED_SVG_IMAGE = 'otpauth://totp/service:account?secret=A4GRFHVVRBGY7UIW&image=' . self::EXTERNAL_INFECTED_IMAGE_URL_ENCODED;
+
const INVALID_OTPAUTH_URI = 'otpauth://Xotp/' . self::ACCOUNT . '?secret=' . self::SECRET;
const INVALID_OTPAUTH_URI_MISMATCHING_ISSUER = 'otpauth://totp/' . self::MICROSOFT . ':' . self::ACCOUNT . '?secret=' . self::SECRET . '&issuer=' . self::SERVICE;
diff --git a/tests/Feature/Services/IconStoreServiceTest.php b/tests/Feature/Services/IconStoreServiceTest.php
index 19c35621..7f33abdb 100644
--- a/tests/Feature/Services/IconStoreServiceTest.php
+++ b/tests/Feature/Services/IconStoreServiceTest.php
@@ -482,11 +482,58 @@ public function test_store_returns_false_when_it_fails()
->with($iconName, $iconContent)
->andReturn(false);
+ Storage::shouldReceive('disk->mimeType')
+ ->with($iconName)
+ ->andReturn('image/png');
+
$result = $this->iconStore->store($iconName, $iconContent);
$this->assertFalse($result);
}
+ #[Test]
+ public function test_store_stores_sanitized_svg_content()
+ {
+ Settings::set('storeIconsInDatabase', true);
+
+ $result = $this->iconStore->store(OtpTestData::ICON_SVG, OtpTestData::ICON_SVG_DATA_INFECTED);
+
+ $this->assertTrue($result);
+
+ $this->assertStringNotContainsString(
+ OtpTestData::ICON_SVG_MALICIOUS_CODE,
+ Storage::disk('icons')->get(OtpTestData::ICON_SVG)
+ );
+
+ $dbRecord = DB::table('icons')->where('name', OtpTestData::ICON_SVG)->first();
+
+ $this->assertStringNotContainsString(
+ OtpTestData::ICON_SVG_MALICIOUS_CODE,
+ $dbRecord->content,
+ );
+ }
+
+ #[Test]
+ public function test_store_returns_false_when_svg_sanitize_failed()
+ {
+ $result = $this->iconStore->store(OtpTestData::ICON_SVG, 'this_will_make_svg_data_invalid' . OtpTestData::ICON_SVG_DATA);
+
+ $this->assertFalse($result);
+ }
+
+ #[Test]
+ public function test_store_deletes_svg_icon_that_cannot_be_sanitized()
+ {
+ Settings::set('storeIconsInDatabase', true);
+
+ $result = $this->iconStore->store(OtpTestData::ICON_SVG, 'this_will_make_svg_data_invalid' . OtpTestData::ICON_SVG_DATA);
+
+ Storage::disk('icons')->assertMissing(OtpTestData::ICON_SVG);
+ $this->assertDatabaseMissing('icons', [
+ 'name' => OtpTestData::ICON_SVG,
+ ]);
+ }
+
#[Test]
public function test_exists_returns_true()
{