Skip to content

Commit dfda63e

Browse files
committed
Added ability for flexible configuration. Added media host clean
1 parent 849a0d3 commit dfda63e

File tree

6 files changed

+318
-75
lines changed

6 files changed

+318
-75
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
}
1515
],
1616
"require": {
17-
"php": ">=7.0.9",
17+
"php": ">=7.4",
1818
"laravel/framework": "^6.20.26|^7.0|^8.0|^9.0"
1919
},
2020
"require-dev": {

src/Cleaner.php

Lines changed: 68 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -5,101 +5,100 @@
55
namespace MasterRO\LaravelXSSFilter;
66

77
use Illuminate\Support\Arr;
8+
use Illuminate\Support\Str;
89

9-
/**
10-
* Class Cleaner
11-
*
12-
* @package MasterRO\LaravelXSSFilter
13-
*/
1410
class Cleaner
1511
{
16-
/**
17-
* @var string
18-
*/
19-
protected $scriptsAndIframesPattern = '/(<script.*script>|<frame.*frame>|<iframe.*iframe>|<object.*object>|<embed.*embed>)/isU';
20-
21-
/**
22-
* @var string
23-
*/
24-
protected $inlineListenersPattern = '/(\bon[A-z]+=(\"|\').*(\"|\')(?=.*>)|(javascript:.*(?=.(\'|")??>)(\)|;)??))/isU';
25-
26-
/**
27-
* @var string
28-
*/
29-
protected $invalidHtmlInlineListenersPattern = '/\bon[A-z]+=(\"|\')?.*(\"|\')?(?=.*>)/isU';
30-
31-
/**
32-
* Clean
33-
*
34-
* @param string $value
35-
*
36-
* @return string
37-
*/
12+
protected CleanerConfig $config;
13+
14+
public function __construct(CleanerConfig $config)
15+
{
16+
$this->config = $config;
17+
}
18+
19+
public function withConfig(CleanerConfig $config): Cleaner
20+
{
21+
$this->config = $config;
22+
23+
return $this;
24+
}
25+
26+
public function config(): CleanerConfig
27+
{
28+
return $this->config;
29+
}
30+
3831
public function clean(string $value): string
3932
{
40-
$value = $this->escapeScriptsAndIframes($value);
41-
$value = config('xss-filter.escape_inline_listeners', false)
33+
$value = $this->escapeElements($value);
34+
$value = $this->cleanMediaElements($value);
35+
36+
return $this->config->shouldEscapeInlineListeners()
4237
? $this->escapeInlineEventListeners($value)
4338
: $this->removeInlineEventListeners($value);
39+
}
40+
41+
public function escapeElements(string $value): string
42+
{
43+
preg_match_all($this->config->elementsPattern(), $value, $matches);
44+
45+
foreach (Arr::get($matches, '0', []) as $htmlElement) {
46+
$value = str_replace($htmlElement, e($htmlElement), $value);
47+
}
4448

4549
return $value;
4650
}
4751

48-
/**
49-
* Escape Scripts And Iframes
50-
*
51-
* @param string $value
52-
*
53-
* @return string
54-
*/
55-
protected function escapeScriptsAndIframes(string $value): string
52+
public function cleanMediaElements(string $value): string
5653
{
57-
preg_match_all($this->scriptsAndIframesPattern, $value, $matches);
54+
if (! $this->config->allowedMediaHosts()) {
55+
return $value;
56+
}
5857

59-
foreach (Arr::get($matches, '0', []) as $script) {
60-
$value = str_replace($script, e($script), $value);
58+
$allowedUrls = collect($this->config->allowedMediaHosts())
59+
->map(
60+
fn(string $host) => ! Str::startsWith($host, ['http', 'https'])
61+
? ["http://{$host}", "https://{$host}"]
62+
: [$host]
63+
)
64+
->flatten()
65+
->all();
66+
67+
preg_match_all($this->config->mediaElementsPattern(), $value, $matches);
68+
69+
foreach (Arr::get($matches, '0', []) as $htmlElement) {
70+
preg_match_all('/src="(.*)"/isU', $htmlElement, $sources);
71+
72+
$urls = Arr::get($sources, '1', []);
73+
74+
foreach ($urls as $url) {
75+
if (! Str::startsWith($url, $allowedUrls)) {
76+
$value = str_replace($url, '#!', $value);
77+
}
78+
}
6179
}
6280

6381
return $value;
6482
}
6583

66-
/**
67-
* Remove Inline Event Listeners
68-
*
69-
* @param string $value
70-
*
71-
* @return string
72-
*/
73-
protected function removeInlineEventListeners(string $value): string
84+
public function removeInlineEventListeners(string $value): string
7485
{
75-
$string = preg_replace($this->inlineListenersPattern, '', $value);
76-
$string = preg_replace($this->invalidHtmlInlineListenersPattern, '', $string);
86+
foreach ($this->config->inlineListenersPatterns() as $pattern) {
87+
$value = preg_replace($pattern, '', $value);
88+
}
7789

78-
return ! is_string($string) ? '' : $string;
90+
return ! is_string($value) ? '' : $value;
7991
}
8092

81-
/**
82-
* Escape Inline Event Listeners
83-
*
84-
* @param string $value
85-
*
86-
* @return string
87-
*/
88-
protected function escapeInlineEventListeners(string $value): string
93+
public function escapeInlineEventListeners(string $value): string
8994
{
90-
$string = preg_replace_callback($this->inlineListenersPattern, [$this, 'escapeEqualSign'], $value);
91-
$string = preg_replace_callback($this->invalidHtmlInlineListenersPattern, [$this, 'escapeEqualSign'], $string);
95+
foreach ($this->config->inlineListenersPatterns() as $pattern) {
96+
$value = preg_replace_callback($pattern, [$this, 'escapeEqualSign'], $value);
97+
}
9298

93-
return ! is_string($string) ? '' : $string;
99+
return ! is_string($value) ? '' : $value;
94100
}
95101

96-
/**
97-
* Escape Equal Sign
98-
*
99-
* @param array $matches
100-
*
101-
* @return string
102-
*/
103102
protected function escapeEqualSign(array $matches): string
104103
{
105104
return str_replace('=', '&#x3d;', $matches[0]);

src/CleanerConfig.php

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MasterRO\LaravelXSSFilter;
6+
7+
use Illuminate\Support\Str;
8+
9+
class CleanerConfig
10+
{
11+
protected ?array $allowedElements = null;
12+
13+
protected array $blockedElements = ['script', 'frame', 'iframe', 'object', 'embed'];
14+
15+
protected array $mediaElements = ['img', 'audio', 'video', 'iframe'];
16+
17+
// If this value set to `true` inline listeners will be escaped, otherwise they will be removed.
18+
protected bool $escapeInlineListeners = false;
19+
20+
/**
21+
* Image/Audio/Video/Iframe hosts that should be retained (by default, all hosts are allowed).
22+
*
23+
* @var list<string>|null
24+
*/
25+
protected ?array $allowedMediaHosts = null;
26+
27+
protected string $inlineListenersPattern = '/(\bon[A-z]+=(\"|\').*(\"|\')(?=.*>)|(javascript:.*(?=.(\'|")??>)(\)|;)??))/isU';
28+
29+
protected string $invalidHtmlInlineListenersPattern = '/\bon[A-z]+=(\"|\')?.*(\"|\')?(?=.*>)/isU';
30+
31+
public static function make(): CleanerConfig
32+
{
33+
return new static();
34+
}
35+
36+
public static function fromArray(array $config): CleanerConfig
37+
{
38+
$config = static::make();
39+
40+
foreach ($config as $key => $value) {
41+
$setter = Str::camel($key);
42+
43+
if (method_exists($config, $setter)) {
44+
$config->{$setter}($value);
45+
}
46+
}
47+
48+
return $config;
49+
}
50+
51+
/**
52+
* Configures the given element as allowed.
53+
*
54+
* Allowed elements are elements the cleaner should retain from the input.
55+
*/
56+
public function allowElement(string $element): CleanerConfig
57+
{
58+
$this->allowedElements[] = $element;
59+
60+
return $this;
61+
}
62+
63+
/**
64+
* Configures the given element as media.
65+
*
66+
* Allowed elements are elements the cleaner should retain from the input.
67+
*/
68+
public function addMediaElement(string $element): CleanerConfig
69+
{
70+
$this->mediaElements[] = $element;
71+
72+
return $this;
73+
}
74+
75+
/**
76+
* Configures the given element as not media.
77+
*
78+
* Allowed elements are elements the cleaner should retain from the input.
79+
*/
80+
public function removeMediaElement(string $element): CleanerConfig
81+
{
82+
$this->mediaElements = array_filter(
83+
$this->mediaElements,
84+
fn(string $el) => $el !== $element,
85+
);
86+
87+
return $this;
88+
}
89+
90+
/**
91+
* Configures the given element as blocked.
92+
*
93+
* Blocked elements are elements the cleaner should escape from the input.
94+
*/
95+
public function blockElement(string $element): CleanerConfig
96+
{
97+
$this->blockedElements[] = $element;
98+
99+
return $this;
100+
}
101+
102+
/**
103+
* Allows only a given list of hosts to be used in media source attributes (img, audio, video, iframe...).
104+
*
105+
* All other hosts will be dropped. By default, all hosts are allowed
106+
* ($allowMediaHosts = null).
107+
*
108+
* @param list<string>|null $allowMediaHosts
109+
*/
110+
public function allowMediaHosts(?array $allowMediaHosts): CleanerConfig
111+
{
112+
$this->allowedMediaHosts = $allowMediaHosts;
113+
114+
return $this;
115+
}
116+
117+
public function elementsPattern(): string
118+
{
119+
$pattern = collect($this->blockedElements)
120+
->reject(fn(string $element) => $this->allowedElements && in_array($element, $this->allowedElements))
121+
->map(fn(string $element) => "<{$element}.*{$element}>")
122+
->implode('|');
123+
124+
return "/({$pattern})/isU";
125+
}
126+
127+
public function mediaElementsPattern(): string
128+
{
129+
$pattern = collect($this->mediaElements)
130+
->map(fn(string $element) => "<{$element}.*{$element}>")
131+
->implode('|');
132+
133+
return "/({$pattern})/isU";
134+
}
135+
136+
/**
137+
* @return list<string>|array|string[]
138+
*/
139+
public function inlineListenersPatterns(): array
140+
{
141+
return [$this->inlineListenersPattern, $this->invalidHtmlInlineListenersPattern];
142+
}
143+
144+
public function shouldEscapeInlineListeners(): bool
145+
{
146+
return $this->escapeInlineListeners;
147+
}
148+
149+
public function allowedMediaHosts(): ?array
150+
{
151+
return $this->allowedMediaHosts;
152+
}
153+
154+
public function setAllowedElements(?array $allowedElements): CleanerConfig
155+
{
156+
$this->allowedElements = $allowedElements;
157+
158+
return $this;
159+
}
160+
161+
public function setBlockedElements(array $blockedElements): CleanerConfig
162+
{
163+
$this->blockedElements = $blockedElements;
164+
165+
return $this;
166+
}
167+
168+
public function setMediaElements(array $mediaElements): CleanerConfig
169+
{
170+
$this->mediaElements = $mediaElements;
171+
172+
return $this;
173+
}
174+
175+
public function setEscapeInlineListeners(bool $escapeInlineListeners): CleanerConfig
176+
{
177+
$this->escapeInlineListeners = $escapeInlineListeners;
178+
179+
return $this;
180+
}
181+
182+
public function setAllowedMediaHosts(?array $allowedMediaHosts): CleanerConfig
183+
{
184+
$this->allowedMediaHosts = $allowedMediaHosts;
185+
186+
return $this;
187+
}
188+
}

src/XSSFilterServiceProvider.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ public function boot()
1818
$this->publishes([
1919
__DIR__ . '/xss-filter.php' => config_path('xss-filter.php'),
2020
], 'config');
21-
2221
}
2322

2423
/**
@@ -29,6 +28,9 @@ public function boot()
2928
public function register()
3029
{
3130
$this->mergeConfigFrom(__DIR__ . '/xss-filter.php', 'xss-filter');
32-
}
3331

32+
$this->app->singleton(Cleaner::class, static function () {
33+
return new Cleaner(CleanerConfig::fromArray(config('xss-filter')));
34+
});
35+
}
3436
}

0 commit comments

Comments
 (0)