Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/Assets/Asset.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use League\Flysystem\PathTraversalDetected;
use Rhukster\DomSanitizer\DOMSanitizer;
use Statamic\Assets\AssetUploader as Uploader;
use Statamic\Contracts\Assets\Asset as AssetContract;
use Statamic\Contracts\Assets\AssetContainer as AssetContainerContract;
Expand Down Expand Up @@ -46,6 +45,7 @@
use Statamic\Statamic;
use Statamic\Support\Arr;
use Statamic\Support\Str;
use Statamic\Support\Svg;
use Statamic\Support\Traits\FluentlyGetsAndSets;
use Statamic\Support\Traits\Hookable;
use Symfony\Component\HttpFoundation\File\UploadedFile;
Expand Down Expand Up @@ -970,9 +970,7 @@ public function reupload(ReplacementFile $file)

$this->disk()->put(
$this->path(),
(new DOMSanitizer(DOMSanitizer::SVG))->sanitize($contents, [
'remove-xml-tags' => ! Str::startsWith($contents, '<?xml'),
])
Svg::sanitize($contents)
);
}

Expand Down
7 changes: 2 additions & 5 deletions src/Assets/Uploader.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
namespace Statamic\Assets;

use Facades\Statamic\Imaging\ImageValidator;
use Rhukster\DomSanitizer\DOMSanitizer;
use Statamic\Facades\Glide;
use Statamic\Support\Str;
use Statamic\Support\Svg;
use Symfony\Component\HttpFoundation\File\UploadedFile;

abstract class Uploader
Expand Down Expand Up @@ -59,10 +59,7 @@ private function write($sourcePath, $destinationPath)
$stream = fopen($sourcePath, 'r');

if (config('statamic.assets.svg_sanitization_on_upload', true) && Str::endsWith($destinationPath, '.svg')) {
$sanitizer = new DOMSanitizer(DOMSanitizer::SVG);
$stream = $sanitizer->sanitize($svg = stream_get_contents($stream), [
'remove-xml-tags' => ! Str::startsWith($svg, '<?xml'),
]);
$stream = Svg::sanitize(stream_get_contents($stream));
}

$this->disk()->put($this->uploadPathPrefix().$destinationPath, $stream);
Expand Down
7 changes: 1 addition & 6 deletions src/CP/Navigation/NavItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
namespace Statamic\CP\Navigation;

use Illuminate\Support\Collection;
use Rhukster\DomSanitizer\DOMSanitizer;
use Statamic\CommandPalette\Category;
use Statamic\CommandPalette\Link;
use Statamic\Facades\CP\Nav;
Expand Down Expand Up @@ -217,11 +216,7 @@ public function svg()
private function sanitizeSvg(string $svg): string
{
try {
$sanitizer = new DOMSanitizer(DOMSanitizer::SVG);

return $sanitizer->sanitize($svg, [
'remove-xml-tags' => ! Str::startsWith($svg, '<?xml'),
]);
return Svg::sanitize($svg);
} catch (\Throwable $e) {
return '';
}
Expand Down
44 changes: 44 additions & 0 deletions src/Support/Svg.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Statamic\Support;

use Rhukster\DomSanitizer\DOMSanitizer;
use Stringy\StaticStringy;

class Svg
Expand All @@ -14,4 +15,47 @@ public static function withClasses(string $svg, ?string $classes = null): string

return str_replace('<svg', sprintf('<svg%s', $attrs), $svg);
}

public static function sanitize(string $svg, ?DOMSanitizer $sanitizer = null): string
{
$sanitizer = $sanitizer ?? new DOMSanitizer(DOMSanitizer::SVG);

$svg = $sanitizer->sanitize($svg, [
'remove-xml-tags' => ! Str::startsWith($svg, '<?xml'),
]);

return static::sanitizeStyleTags($svg);
}

public static function sanitizeCss(string $css): string
{
// Decode all CSS escape sequences in a single pass to prevent bypass.
// Hex escapes: \69mport -> import. Non-hex escapes: \i -> i, \@ -> @.
$css = preg_replace_callback(
'/\\\\(?:([0-9a-fA-F]{1,6})\s?|(.))/s',
fn ($m) => ($m[1] !== '') ? mb_chr(hexdec($m[1]), 'UTF-8') : $m[2],
$css
);

// Normalize Unicode whitespace and invisible characters to ASCII spaces
// so they can't be used to sneak past the regex patterns below
$css = preg_replace('/[\p{Z}\x{200B}\x{FEFF}]+/u', ' ', $css);

// Remove @import rules entirely
$css = preg_replace('/@import\s+[^;]+;?/i', '', $css);

// Neutralize url() references to external resources (http, https, protocol-relative)
$css = preg_replace('/url\s*\(\s*["\']?\s*(?:https?:|\/\/)[^)]*\)/i', 'url()', $css);

return $css;
}

private static function sanitizeStyleTags(string $svg): string
{
return preg_replace_callback(
'/<style([^>]*)>(.*?)<\/style>/si',
fn ($matches) => '<style'.$matches[1].'>'.static::sanitizeCss($matches[2]).'</style>',
$svg
);
}
}
5 changes: 2 additions & 3 deletions src/Tags/Svg.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Statamic\Facades\File;
use Statamic\Facades\Path;
use Statamic\Support\Str;
use Statamic\Support\Svg as SvgSupport;
use Stringy\StaticStringy;

class Svg extends Tags
Expand Down Expand Up @@ -105,9 +106,7 @@ private function sanitize($svg)
$this->setAllowedAttrs($sanitizer);
$this->setAllowedTags($sanitizer);

return $sanitizer->sanitize($svg, [
'remove-xml-tags' => ! Str::startsWith($svg, '<?xml'),
]);
return SvgSupport::sanitize($svg, $sanitizer);
}

private function setAllowedAttrs(DOMSanitizer $sanitizer)
Expand Down
161 changes: 161 additions & 0 deletions tests/Support/SvgTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

namespace Tests\Support;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Support\Svg;
use Tests\TestCase;

class SvgTest extends TestCase
{
#[Test]
#[DataProvider('sanitizeCssProvider')]
public function it_sanitizes_css(string $input, string $expected)
{
$this->assertSame($expected, trim(Svg::sanitizeCss($input)));
}

public static function sanitizeCssProvider()
{
return [
'strips @import with url()' => [
'@import url("https://evil.com/x.css");',
'',
],
'strips @import with bare string' => [
'@import "https://evil.com/x.css";',
'',
],
'strips @import with protocol-relative url' => [
'@import url(//evil.com/x.css);',
'',
],
'strips @import without semicolon' => [
"@import url('https://evil.com/x.css')",
'',
],
'strips @import using hex escapes' => [
'@\\69mport url("https://evil.com/x.css");',
'',
],
'strips @import using non-hex backslash escapes' => [
'@\import url("https://evil.com/x.css");',
'',
],
'strips @import using mixed hex and non-hex escapes' => [
'@\\69\mport url("https://evil.com/x.css");',
'',
],
'neutralizes external url' => [
'.cls { background: url(https://evil.com/beacon.gif); }',
'.cls { background: url(); }',
],
'neutralizes protocol-relative url' => [
'.cls { background: url(//evil.com/x); }',
'.cls { background: url(); }',
],
'neutralizes quoted external url' => [
'.cls { background: url("http://evil.com/x"); }',
'.cls { background: url(); }',
],
'neutralizes external url using hex escapes' => [
'.cls { background: url(\\68\\74\\74\\70\\73://evil.com/beacon.gif); }',
'.cls { background: url(); }',
],
'neutralizes external url using non-hex backslash escapes' => [
'.cls { background: url(\https://evil.com/x); }',
'.cls { background: url(); }',
],
'neutralizes external url using non-breaking space escape' => [
'.cls { background: url(\\a0 https://evil.com/x); }',
'.cls { background: url(); }',
],
'neutralizes external url using zero-width space escape' => [
'.cls { background: url(\\200B https://evil.com/x); }',
'.cls { background: url(); }',
],
'neutralizes external url using BOM escape' => [
'.cls { background: url(\\FEFF https://evil.com/x); }',
'.cls { background: url(); }',
],
'neutralizes external url in @font-face src' => [
'@font-face { font-family: "x"; src: url("https://evil.com/font.woff"); }',
'@font-face { font-family: "x"; src: url(); }',
],
'preserves normal css' => [
'.cls-1 { fill: #333; stroke: red; }',
'.cls-1 { fill: #333; stroke: red; }',
],
'preserves internal url references' => [
'.cls { fill: url(#myGradient); }',
'.cls { fill: url(#myGradient); }',
],
'preserves data uris' => [
'.cls { background: url(data:image/png;base64,abc123); }',
'.cls { background: url(data:image/png;base64,abc123); }',
],
'handles mixed legitimate and malicious css' => [
".cls-1 { fill: #333; }\n@import url(\"https://evil.com/track.css\");\n.cls-2 { stroke: url(#grad); background: url(https://evil.com/bg.gif); }",
".cls-1 { fill: #333; }\n\n.cls-2 { stroke: url(#grad); background: url(); }",
],
];
}

#[Test]
public function it_sanitizes_style_tags_in_full_svg()
{
$svg = '<svg xmlns="http://www.w3.org/2000/svg"><style>@import url("https://evil.com/track.css"); .cls-1 { fill: #333; }</style><rect class="cls-1"/></svg>';

$result = Svg::sanitize($svg);

$this->assertStringNotContainsString('@import', $result);
$this->assertStringNotContainsString('evil.com', $result);
$this->assertStringContainsString('.cls-1', $result);
$this->assertStringContainsString('fill:', $result);
}

#[Test]
public function it_passes_through_svg_without_style_tags()
{
$svg = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1" fill="white"/></svg>';

$result = Svg::sanitize($svg);

$this->assertStringContainsString('<rect', $result);
$this->assertStringContainsString('<svg', $result);
}

#[Test]
public function it_preserves_xml_declaration()
{
$svg = '<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>';

$result = Svg::sanitize($svg);

$this->assertStringStartsWith('<?xml', $result);
}

#[Test]
public function it_does_not_add_xml_declaration()
{
$svg = '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>';

$result = Svg::sanitize($svg);

$this->assertStringStartsWith('<svg', $result);
}

#[Test]
public function it_sanitizes_css_inside_cdata_sections()
{
$svg = '<svg xmlns="http://www.w3.org/2000/svg"><style><![CDATA[@import url("https://evil.com/track.css"); .cls-1 { fill: url(https://evil.com/bg.gif); }]]></style><rect class="cls-1"/></svg>';

$result = Svg::sanitize($svg);

$this->assertStringNotContainsString('@import', $result);
$this->assertStringNotContainsString('evil.com', $result);
$this->assertStringContainsString('.cls-1', $result);
$this->assertStringContainsString('fill:', $result);
}
}
13 changes: 13 additions & 0 deletions tests/Tags/SvgTagTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ public function sanitization_can_be_disabled()
$this->assertEquals('<svg><path/></svg>', $this->tag('{{ svg src="xss" }}'));
}

#[Test]
public function it_sanitizes_css_in_style_tags()
{
File::put(resource_path('css-inject.svg'), '<svg xmlns="http://www.w3.org/2000/svg"><style>@import url("https://evil.com/track.css"); .cls-1 { fill: #333; }</style><rect class="cls-1"/></svg>');

$result = $this->tag('{{ svg src="css-inject" }}');

$this->assertStringNotContainsString('@import', $result);
$this->assertStringNotContainsString('evil.com', $result);
$this->assertStringContainsString('.cls-1', $result);
$this->assertStringContainsString('fill:', $result);
}

#[Test]
public function fails_gracefully_when_src_is_empty()
{
Expand Down
Loading