Fixed FD-55359 - sanitize CSS
This commit is contained in:
+1
-1
@@ -133,7 +133,7 @@ BS_TABLE_DEEPLINK=true
|
||||
APP_TRUSTED_PROXIES=192.168.1.1,10.0.0.1
|
||||
ALLOW_IFRAMING=false
|
||||
REFERRER_POLICY=same-origin
|
||||
ENABLE_CSP=false
|
||||
ENABLE_CSP=true
|
||||
ADDITIONAL_CSP_URLS=null
|
||||
CORS_ALLOWED_ORIGINS=null
|
||||
ENABLE_HSTS=false
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Asset;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Notifications\CurrentInventory;
|
||||
use App\Rules\CssColor;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -63,6 +64,12 @@ class ProfileController extends Controller
|
||||
|
||||
$user->enable_sounds = $request->input('enable_sounds', false);
|
||||
$user->enable_confetti = $request->input('enable_confetti', false);
|
||||
$request->validate([
|
||||
'link_light_color' => ['nullable', new CssColor],
|
||||
'link_dark_color' => ['nullable', new CssColor],
|
||||
'nav_link_color' => ['nullable', new CssColor],
|
||||
]);
|
||||
|
||||
$user->link_light_color = $request->input('link_light_color', '#296282');
|
||||
$user->link_dark_color = $request->input('link_dark_color', '#296282');
|
||||
$user->nav_link_color = $request->input('nav_link_color', '#FFFFFF');
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Models\Group;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Notifications\MailTest;
|
||||
use App\Rules\CssColor;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -189,6 +190,13 @@ class SettingsController extends Controller
|
||||
$request->validate(['site_name' => 'required']);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'header_color' => ['nullable', new CssColor],
|
||||
'link_light_color' => ['nullable', new CssColor],
|
||||
'link_dark_color' => ['nullable', new CssColor],
|
||||
'nav_link_color' => ['nullable', new CssColor],
|
||||
]);
|
||||
|
||||
$setting->header_color = $request->input('header_color', '#3c8dbc');
|
||||
$setting->link_light_color = $request->input('link_light_color', '#296282');
|
||||
$setting->link_dark_color = $request->input('link_dark_color', '#5fa4cc');
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Requests\SetupUserRequest;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Notifications\FirstAdminNotification;
|
||||
use App\Rules\CssColor;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -166,6 +167,12 @@ class SetupController extends Controller
|
||||
$settings->alerts_enabled = 1;
|
||||
$settings->pwd_secure_min = 10;
|
||||
$settings->brand = 1;
|
||||
$request->validate([
|
||||
'link_light_color' => ['nullable', new CssColor],
|
||||
'link_dark_color' => ['nullable', new CssColor],
|
||||
'nav_link_color' => ['nullable', new CssColor],
|
||||
]);
|
||||
|
||||
$settings->link_light_color = $request->input('link_light_color', '#296282');
|
||||
$settings->link_dark_color = $request->input('link_dark_color', '#296282');
|
||||
$settings->nav_link_color = $request->input('nav_link_color', '#FFFFFF');
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Rules\CssColor;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
@@ -173,6 +175,34 @@ class Setting extends Model
|
||||
*
|
||||
* @author A. Gianotto <snipe@snipe.net>
|
||||
*/
|
||||
protected function headerColor(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => CssColor::sanitize($value, '#3c8dbc'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function linkLightColor(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => CssColor::sanitize($value, '#296282'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function linkDarkColor(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => CssColor::sanitize($value, '#5fa4cc'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function navLinkColor(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => CssColor::sanitize($value, '#ffffff'),
|
||||
);
|
||||
}
|
||||
|
||||
public function show_custom_css(): string
|
||||
{
|
||||
$custom_css = self::getSettings()->custom_css;
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Traits\Loggable;
|
||||
use App\Models\Traits\Searchable;
|
||||
use App\Presenters\Presentable;
|
||||
use App\Presenters\UserPresenter;
|
||||
use App\Rules\CssColor;
|
||||
use Illuminate\Auth\Authenticatable;
|
||||
use Illuminate\Auth\Passwords\CanResetPassword;
|
||||
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
|
||||
@@ -713,6 +714,27 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
return $this->last_name ? $this->first_name.' '.$this->last_name : $this->first_name;
|
||||
}
|
||||
|
||||
protected function linkLightColor(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => CssColor::sanitize($value, '#296282'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function linkDarkColor(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => CssColor::sanitize($value, '#5fa4cc'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function navLinkColor(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => CssColor::sanitize($value, '#ffffff'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the user -> assets relationship
|
||||
*
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
class CssColor implements ValidationRule
|
||||
{
|
||||
private static function pattern(): string
|
||||
{
|
||||
$num = '\s*[\d.]+\s*';
|
||||
$pct = '\s*[\d.]+%\s*';
|
||||
$alpha = '(?:,\s*[\d.]+\s*)?';
|
||||
$hex = '#[0-9a-fA-F]{3,8}';
|
||||
$rgb = "rgba?\({$num},{$num},{$num}{$alpha}\)";
|
||||
$hsl = "hsla?\({$num},{$pct},{$pct}{$alpha}\)";
|
||||
|
||||
return "/^(?:{$hex}|{$rgb}|{$hsl})$/i";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return $value if it is a safe CSS color, otherwise return $default.
|
||||
* Use this for defense-in-depth when rendering color values already in the database.
|
||||
*/
|
||||
public static function sanitize(?string $value, string $default): string
|
||||
{
|
||||
if ($value && preg_match(self::pattern(), trim($value))) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! preg_match(self::pattern(), $value)) {
|
||||
$fail(trans('validation.valid_css_color'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,7 @@ return [
|
||||
'url' => 'The :attribute field must be a valid URL.',
|
||||
'ulid' => 'The :attribute field must be a valid ULID.',
|
||||
'uuid' => 'The :attribute field must be a valid UUID.',
|
||||
'valid_css_color' => 'The :attribute field must be a valid CSS color (hex, rgb, rgba, hsl, or hsla).',
|
||||
'fmcs_location' => 'Full multiple company support and location scoping is enabled in the Admin Settings, and the selected location and selected company are not compatible.',
|
||||
'is_unique_across_company_and_location' => 'The :attribute must be unique within the selected company and location.',
|
||||
|
||||
|
||||
@@ -6,10 +6,96 @@ use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BrandingSettingsTest extends TestCase
|
||||
{
|
||||
public static function validColorProvider(): array
|
||||
{
|
||||
return [
|
||||
'hex 6-digit' => ['#3c8dbc'],
|
||||
'hex 3-digit' => ['#fff'],
|
||||
'rgb' => ['rgb(10,20,30)'],
|
||||
'rgba' => ['rgba(10,20,30,0.5)'],
|
||||
'hsl' => ['hsl(120,50%,50%)'],
|
||||
'hsla' => ['hsla(120,50%,50%,0.8)'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function invalidColorProvider(): array
|
||||
{
|
||||
return [
|
||||
'named color' => ['red'],
|
||||
'css injection payload' => ["red; }body{background:url(//evil.com)} .x{color: #"],
|
||||
'url()' => ['url(http://evil.com)'],
|
||||
'value with semicolon' => ['#3c8dbc; color: red'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('validColorProvider')]
|
||||
public function test_valid_header_color_can_be_saved(string $color): void
|
||||
{
|
||||
$this->actingAs(User::factory()->superuser()->create())
|
||||
->post(route('settings.branding.save'), ['header_color' => $color])
|
||||
->assertValid('header_color')
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('settings', ['header_color' => $color]);
|
||||
}
|
||||
|
||||
#[DataProvider('invalidColorProvider')]
|
||||
public function test_invalid_header_color_is_rejected(string $color): void
|
||||
{
|
||||
$this->actingAs(User::factory()->superuser()->create())
|
||||
->from(route('settings.branding.index'))
|
||||
->post(route('settings.branding.save'), ['header_color' => $color])
|
||||
->assertInvalid(['header_color'])
|
||||
->assertSessionHasErrors(['header_color']);
|
||||
}
|
||||
|
||||
#[DataProvider('validColorProvider')]
|
||||
public function test_valid_link_colors_can_be_saved(string $color): void
|
||||
{
|
||||
$this->actingAs(User::factory()->superuser()->create())
|
||||
->post(route('settings.branding.save'), [
|
||||
'link_light_color' => $color,
|
||||
'link_dark_color' => $color,
|
||||
'nav_link_color' => $color,
|
||||
])
|
||||
->assertValid(['link_light_color', 'link_dark_color', 'nav_link_color'])
|
||||
->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
#[DataProvider('invalidColorProvider')]
|
||||
public function test_invalid_link_colors_are_rejected(string $color): void
|
||||
{
|
||||
$this->actingAs(User::factory()->superuser()->create())
|
||||
->from(route('settings.branding.index'))
|
||||
->post(route('settings.branding.save'), [
|
||||
'link_light_color' => $color,
|
||||
'link_dark_color' => $color,
|
||||
'nav_link_color' => $color,
|
||||
])
|
||||
->assertInvalid(['link_light_color', 'link_dark_color', 'nav_link_color'])
|
||||
->assertSessionHasErrors(['link_light_color', 'link_dark_color', 'nav_link_color']);
|
||||
}
|
||||
|
||||
public function test_setting_model_sanitizes_corrupt_header_color(): void
|
||||
{
|
||||
$setting = Setting::factory()->create();
|
||||
$setting->setRawAttributes(['header_color' => 'red; }body{color:red}']);
|
||||
|
||||
$this->assertSame('#3c8dbc', $setting->header_color);
|
||||
}
|
||||
|
||||
public function test_setting_model_passes_through_valid_header_color(): void
|
||||
{
|
||||
$setting = Setting::factory()->create(['header_color' => '#5fa4cc']);
|
||||
|
||||
$this->assertSame('#5fa4cc', $setting->header_color);
|
||||
}
|
||||
|
||||
public function test_site_name_is_required()
|
||||
{
|
||||
$response = $this->actingAs(User::factory()->superuser()->create())
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Rules;
|
||||
|
||||
use App\Rules\CssColor;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CssColorTest extends TestCase
|
||||
{
|
||||
public static function validColorProvider(): array
|
||||
{
|
||||
return [
|
||||
'hex 3-digit' => ['#abc'],
|
||||
'hex 6-digit' => ['#3c8dbc'],
|
||||
'hex uppercase' => ['#FFFFFF'],
|
||||
'hex 4-digit rgba' => ['#abcd'],
|
||||
'hex 8-digit rgba' => ['#3c8dbc80'],
|
||||
'rgb' => ['rgb(10,20,30)'],
|
||||
'rgb with spaces' => ['rgb( 10 , 20 , 30 )'],
|
||||
'rgba' => ['rgba(10,20,30,0.5)'],
|
||||
'hsl' => ['hsl(120,50%,50%)'],
|
||||
'hsla' => ['hsla(120,50%,50%,0.8)'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function invalidColorProvider(): array
|
||||
{
|
||||
return [
|
||||
'named color' => ['red'],
|
||||
'css injection payload' => ["red; }body{background:url(//evil.com)} .x{color: #"],
|
||||
'expression' => ['expression(alert(1))'],
|
||||
'url()' => ['url(http://evil.com)'],
|
||||
'value with semicolon' => ['#3c8dbc; color: red'],
|
||||
'empty string' => [''],
|
||||
'arbitrary string' => ['not-a-color'],
|
||||
'javascript scheme' => ['javascript:alert(1)'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('validColorProvider')]
|
||||
public function test_validate_passes_for_valid_colors(string $color): void
|
||||
{
|
||||
$failed = false;
|
||||
(new CssColor)->validate('color', $color, function () use (&$failed) {
|
||||
$failed = true;
|
||||
});
|
||||
|
||||
$this->assertFalse($failed, "Expected '{$color}' to pass validation but it failed.");
|
||||
}
|
||||
|
||||
#[DataProvider('invalidColorProvider')]
|
||||
public function test_validate_fails_for_invalid_colors(string $color): void
|
||||
{
|
||||
$failed = false;
|
||||
(new CssColor)->validate('color', $color, function () use (&$failed) {
|
||||
$failed = true;
|
||||
});
|
||||
|
||||
$this->assertTrue($failed, "Expected '{$color}' to fail validation but it passed.");
|
||||
}
|
||||
|
||||
#[DataProvider('validColorProvider')]
|
||||
public function test_sanitize_returns_value_for_valid_colors(string $color): void
|
||||
{
|
||||
$this->assertSame($color, CssColor::sanitize($color, '#000000'));
|
||||
}
|
||||
|
||||
#[DataProvider('invalidColorProvider')]
|
||||
public function test_sanitize_returns_default_for_invalid_colors(string $color): void
|
||||
{
|
||||
$this->assertSame('#000000', CssColor::sanitize($color, '#000000'));
|
||||
}
|
||||
|
||||
public function test_sanitize_returns_default_for_null(): void
|
||||
{
|
||||
$this->assertSame('#fallback', CssColor::sanitize(null, '#fallback'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user