PluginProbe
The Innovative Form Builder – IvyForms / 1.4
The Innovative Form Builder – IvyForms v1.4
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / backend / src / Common / Sanitizer / CssSanitizer.php

CssSanitizer.php in The Innovative Form Builder – IvyForms 1.4, at backend/src/Common/Sanitizer/CssSanitizer.php

322 lines 8.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * @copyright © Melograno Venture Studio. All rights reserved.
5 * @licence See COPYING.md for license details.
6 */
7
8 namespace IvyForms\Common\Sanitizer;
9
10 class CssSanitizer
11 {
12 /**
13 * Patterns matched per declaration (not whole style string — `;` separates declarations).
14 *
15 * @return array<int, string>
16 */
17 private static function declarationDangerousPatterns(): array
18 {
19 return [
20 '/expression\s*\(/i',
21 '/javascript\s*:/i',
22 '/vbscript\s*:/i',
23 '/<\s*script/i',
24 '/on\w+\s*=/i',
25 '/import\s+/i',
26 '/<!--/i',
27 '/-->/i',
28 '/behavior\s*:/i',
29 '/-moz-binding\s*:/i',
30 '/\{/',
31 '/\}/',
32 '/\/\*/',
33 '/\*\//',
34 '/\\\\/i',
35 ];
36 }
37
38 private static function logBlockedDeclaration(string $declaration, string $pattern): void
39 {
40 $cssHash = substr(hash('sha256', $declaration), 0, 12);
41 error_log(
42 '🚨 BLOCKED dangerous CSS declaration' .
43 ' | pattern=' . $pattern .
44 ' | css_hash=' . $cssHash .
45 ' | css_len=' . strlen($declaration)
46 );
47 }
48
49 private static function isDangerousCssDeclaration(string $declaration): bool
50 {
51 foreach (self::declarationDangerousPatterns() as $pattern) {
52 if (preg_match($pattern, $declaration)) {
53 self::logBlockedDeclaration($declaration, $pattern);
54 return true;
55 }
56 }
57
58 return false;
59 }
60
61 /**
62 * @param array<int, string> $matches
63 */
64 private static function rewriteSingleCssUrlMatch(array $matches): string
65 {
66 $url = trim($matches[1]);
67
68 if (preg_match('/[;{}]|\/\*|\*\//', $url)) {
69 $urlHash = substr(hash('sha256', $url), 0, 12);
70 error_log(
71 '🚨 BLOCKED unsafe CSS url() rule fired' .
72 ' | rule=declaration_breaking_tokens' .
73 ' | url_hash=' . $urlHash .
74 ' | url_len=' . strlen($url)
75 );
76 return '';
77 }
78
79 if (preg_match('/^(https?:|data:image\/)/i', $url)) {
80 return 'url("' . esc_url_raw($url) . '")';
81 }
82
83 $urlHash = substr(hash('sha256', $url), 0, 12);
84 error_log(
85 '🚨 BLOCKED unsafe CSS url() rule fired' .
86 ' | rule=allowed_url_protocol' .
87 ' | url_hash=' . $urlHash .
88 ' | url_len=' . strlen($url)
89 );
90
91 return '';
92 }
93
94 private static function rewriteUrlFunctionsInDeclaration(string $declaration): string
95 {
96 $out = preg_replace_callback(
97 '/url\s*\(\s*["\']?([^"\')]+)["\']?\s*\)/i',
98 /**
99 * Explicit call so PHPMD counts `rewriteSingleCssUrlMatch` as used (callable array is not traced).
100 *
101 * @param array<int, string> $matches
102 */
103 static function (array $matches): string {
104 return self::rewriteSingleCssUrlMatch($matches);
105 },
106 $declaration
107 );
108
109 return $out === null ? '' : trim((string) $out);
110 }
111
112 /**
113 * Sanitize CSS value to prevent XSS attacks while allowing safe color/function values.
114 *
115 * After structural checks and `url()` handling, the result is passed through `esc_attr()`
116 * so it is safe to embed in double-quoted HTML `style` attributes (matches legacy behavior).
117 *
118 * @param string $css Raw inline style declaration string.
119 *
120 * @return string Safe for use inside `style="..."`.
121 */
122 public static function sanitizeCssValue(string $css): string
123 {
124 $sanitizedDeclarations = self::sanitizeCssDeclarationList($css);
125 if ($sanitizedDeclarations === '') {
126 return '';
127 }
128
129 return esc_attr($sanitizedDeclarations);
130 }
131
132 /**
133 * Sanitize a semicolon-separated declaration list for use inside a stylesheet rule block.
134 *
135 * Unlike sanitizeCssValue(), this returns raw CSS (not HTML-escaped) so it can be injected
136 * into a <style> tag safely after declaration-level sanitization.
137 *
138 * @param string $css Raw CSS declaration list.
139 * @return string
140 */
141 public static function sanitizeCssDeclarationList(string $css): string
142 {
143 $css = trim($css);
144 if ($css === '') {
145 return '';
146 }
147
148 $safeDeclarations = [];
149 foreach (explode(';', $css) as $chunk) {
150 $declaration = trim($chunk);
151 if ($declaration === '') {
152 continue;
153 }
154 if (self::isDangerousCssDeclaration($declaration)) {
155 continue;
156 }
157 $sanitized = self::rewriteUrlFunctionsInDeclaration($declaration);
158 if ($sanitized !== '') {
159 $safeDeclarations[] = $sanitized;
160 }
161 }
162
163 if ($safeDeclarations === []) {
164 return '';
165 }
166
167 return implode('; ', $safeDeclarations);
168 }
169
170 /**
171 * Sanitize a single CSS class name.
172 *
173 * @param mixed $value
174 * @return string
175 */
176 public static function sanitizeCssClass($value): string
177 {
178 if (!is_string($value)) {
179 return '';
180 }
181
182 return sanitize_html_class($value);
183 }
184
185 /**
186 * Sanitize a comma-separated list of CSS classes.
187 *
188 * @param mixed $value
189 * @return string
190 */
191 public static function sanitizeCssClassList($value): string
192 {
193 if (!is_string($value)) {
194 return '';
195 }
196
197 $parts = preg_split('/[,\s]+/', $value) ?: [];
198 $uniqueClasses = [];
199
200 foreach ($parts as $part) {
201 $sanitized = sanitize_html_class($part);
202 if ($sanitized !== '') {
203 $uniqueClasses[$sanitized] = true;
204 }
205 }
206
207 return implode(', ', array_keys($uniqueClasses));
208 }
209
210 /**
211 * Sanitize CSS unit value (px, rem, em, %, etc.)
212 *
213 * @param string $value
214 * @return string
215 */
216 public static function sanitizeCssUnit(string $value): string
217 {
218 if (preg_match('/^-?[0-9.]+(?:px|rem|em|%|vh|vw|pt)?$/i', trim($value))) {
219 return sanitize_text_field($value);
220 }
221
222 return '';
223 }
224
225 /**
226 * Sanitize a CSS length or the keyword "auto" (e.g. width / height).
227 *
228 * @param string $value
229 * @return string
230 */
231 public static function sanitizeCssUnitOrAuto(string $value): string
232 {
233 $trimmed = trim($value);
234 if ('' === $trimmed) {
235 return '';
236 }
237
238 if (preg_match('/^auto$/i', $trimmed)) {
239 return 'auto';
240 }
241
242 return self::sanitizeCssUnit($trimmed);
243 }
244
245 /**
246 * @return array<int, string>
247 */
248 private static function strictColorFunctionPatterns(): array
249 {
250 return [
251 '/^rgba?\([0-9,.\s%]+\)$/i',
252 '/^hsla?\([\d\s.,%\/-]+\)$/i',
253 '/^hwb\([\d\s.,%\/-]+\)$/i',
254 '/^var\(--[-\w]+\)$/i',
255 ];
256 }
257
258 private static function matchStrictHexColor(string $color): ?string
259 {
260 if (!preg_match('/^#([a-f0-9]{3}|[a-f0-9]{6}|[a-f0-9]{8})$/i', $color)) {
261 return null;
262 }
263
264 return strlen($color) === 9 ? strtolower($color) : $color;
265 }
266
267 private static function matchStrictFunctionColor(string $color): ?string
268 {
269 foreach (self::strictColorFunctionPatterns() as $pattern) {
270 if (preg_match($pattern, $color)) {
271 return sanitize_text_field($color);
272 }
273 }
274
275 return null;
276 }
277
278 private static function matchStrictNamedColor(string $color): ?string
279 {
280 if (!preg_match('/^[a-z]+$/i', $color) || strlen($color) > 20) {
281 return null;
282 }
283
284 return sanitize_text_field($color);
285 }
286
287 private static function matchKnownStrictColorFormat(string $color): ?string
288 {
289 if (strcasecmp($color, 'transparent') === 0) {
290 return 'transparent';
291 }
292
293 $hexColor = self::matchStrictHexColor($color);
294 if ($hexColor !== null) {
295 return $hexColor;
296 }
297
298 $functionColor = self::matchStrictFunctionColor($color);
299 if ($functionColor !== null) {
300 return $functionColor;
301 }
302
303 return self::matchStrictNamedColor($color);
304 }
305
306 /**
307 * Sanitize color value for advanced style settings — rejects values unsafe for CSS output.
308 *
309 * @param string $color
310 * @return string
311 */
312 public static function sanitizeStrictColor(string $color): string
313 {
314 $color = trim($color);
315 if ($color === '') {
316 return '';
317 }
318
319 return self::matchKnownStrictColorFormat($color) ?? '';
320 }
321 }
322