mirror of
https://github.com/Bubka/2FAuth.git
synced 2024-12-23 23:49:53 +01:00
Sanitize svg icons before storing them
This commit is contained in:
parent
738be76c95
commit
93c508e118
@ -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) {
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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",
|
||||
|
47
composer.lock
generated
47
composer.lock
generated
@ -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",
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -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());
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
@ -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 = '<script>alert("XSS");</script>';
|
||||
|
||||
const ICON_SVG_DATA_INFECTED = '<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100" height="100" version="1.1" xmlns="http://www..w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">' . self::ICON_SVG_MALICIOUS_CODE . '</svg>';
|
||||
|
||||
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;
|
||||
|
@ -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()
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user