PluginProbe ʕ •ᴥ•ʔ
Email Encoder – Protect Email Addresses and Phone Numbers / 2.5.3
Email Encoder – Protect Email Addresses and Phone Numbers v2.5.3
2.5.3 2.5.2 2.5.1 2.5.0 2.4.8 trunk 0.10 0.11 0.12 0.20 0.21 0.22 0.30 0.31 0.32 0.40 0.41 0.42 0.50 0.60 0.70 0.71 0.80 1.0.0 1.0.1 1.0.2 1.1.0 1.2.0 1.2.1 1.3.0 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.5 1.5.2 1.51 1.53 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.1.1 2.1.10 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.1.9 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.3.0 2.3.1 2.3.3 2.3.4 2.3.5 2.3.6 2.3.7 2.3.8 2.3.9 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.4.7
email-encoder-bundle / src / Validate / Filters.php
email-encoder-bundle / src / Validate Last commit date
EncoderForm.php 3 months ago Encoding.php 3 months ago Filters.php 5 days ago Validate.php 7 months ago
Filters.php
590 lines
1 <?php
2
3 namespace OnlineOptimisation\EmailEncoderBundle\Validate;
4
5 use DOMDocument;
6 use OnlineOptimisation\EmailEncoderBundle\Traits\PluginHelper;
7
8 class Filters
9 {
10 use PluginHelper;
11
12 public function boot(): void
13 {
14 }
15
16
17 /**
18 * ######################
19 * ###
20 * #### FILTERS
21 * ###
22 * ######################
23 */
24
25 /**
26 * The main page filter function
27 *
28 * @param string $content - the content that needs to be filtered
29 * @param string $protect_using
30 * @return string - The filtered content
31 */
32 public function filter_page( $content, $protect_using )
33 {
34
35 //Added in 2.0.6
36 $content = apply_filters( 'eeb/validate/filter_page_content', $content, $protect_using );
37
38 $content = $this->filter_soft_dom_attributes( $content, 'char_encode' );
39
40 $htmlSplit = preg_split( '/(<body(([^>]*)>))/is', $content, -1, PREG_SPLIT_DELIM_CAPTURE );
41
42 if ( ! is_array( $htmlSplit) || count( $htmlSplit ) < 4 ) {
43 return $content;
44 }
45
46 switch ( $protect_using ) {
47 case 'with_javascript':
48 case 'without_javascript':
49 case 'char_encode':
50 $head_encoding_method = 'char_encode';
51 break;
52 default:
53 $head_encoding_method = 'default';
54 break;
55 }
56
57 //Filter head area
58 $filtered_head = $this->filter_plain_emails( $htmlSplit[0], null, $head_encoding_method );
59
60 //Filter body
61 //Soft attributes always need to be protected using only the char encode method since otherwise the logic breaks
62 $filtered_body = $this->filter_soft_attributes( $htmlSplit[4], 'char_encode' );
63 $filtered_body = $this->filter_content( $filtered_body, $protect_using );
64
65 $filtered_content = $filtered_head . $htmlSplit[1] . $filtered_body;
66
67 //Revalidate filtered emails that should not bbe encoded
68 $filtered_content = $this->tempEncodeAtSymbol( $filtered_content, true );
69
70 return $filtered_content;
71 }
72
73 /**
74 * Filter content
75 *
76 * @param string $content
77 * @param string $protect_using
78 * @return string
79 */
80 public function filter_content( $content, $protect_using )
81 {
82 $filtered = $content;
83 $self = $this;
84 $encode_mailtos = (bool) $this->getSetting( 'encode_mailtos', true, 'filter_body' );
85 $convert_plain_to_image = (bool) $this->getSetting( 'convert_plain_to_image', true, 'filter_body' );
86
87 //Added in 2.0.6
88 $filtered = apply_filters( 'eeb/validate/filter_content_content', $filtered, $protect_using );
89
90 //Soft attributes always need to be protected using only the char encode method since otherwise the logic breaks
91 $filtered = $this->filter_soft_attributes( $filtered, 'char_encode' );
92
93 // <option>, <textarea>, <title> can only contain text — any <span>/<script>/<img> inside
94 // them is stripped or rendered inconsistently across browsers (Firefox dropdowns show only
95 // the noscript fallback). For modes that emit HTML wrappers, pre-encode emails in those
96 // zones as entities and stash them behind placeholders so the main filter pass skips them.
97 $text_only_tokens = [];
98 $guard_text_only_zones = in_array( $protect_using, [ 'with_javascript', 'without_javascript' ], true );
99 if ( $guard_text_only_zones ) {
100 $filtered = $this->isolate_text_only_zones( $filtered, $text_only_tokens );
101 }
102
103 switch ( $protect_using ) {
104 case 'char_encode':
105 $filtered = $this->filter_plain_emails( $filtered, null, 'char_encode' );
106 break;
107 case 'strong_method':
108 $filtered = $this->filter_plain_emails( $filtered );
109 break;
110 case 'without_javascript':
111 $filtered = $this->filter_input_fields( $filtered, $protect_using );
112 $filtered = $this->filter_mailto_links( $filtered, 'without_javascript' );
113 $filtered = $this->filter_custom_links( $filtered, 'without_javascript' );
114
115 if ( $convert_plain_to_image ) {
116 $replace_by = 'convert_image';
117 } else {
118 $replace_by = 'use_css';
119 }
120
121 if ( $encode_mailtos ) {
122 if ( ! ( function_exists( 'et_fb_enabled' ) && et_fb_enabled() ) ) {
123 $filtered = $this->filter_plain_emails( $filtered, function ( $match ) use ( $self ) {
124 return $self->createProtectedMailto( $match[0], array( 'href' => 'mailto:' . $match[0] ), 'without_javascript' );
125 }, $replace_by);
126 } else {
127 $filtered = $this->filter_plain_emails( $filtered, null, $replace_by );
128 }
129 } else {
130 $filtered = $this->filter_plain_emails( $filtered, null, $replace_by );
131 }
132
133 break;
134 case 'with_javascript':
135 $filtered = $this->filter_input_fields( $filtered, $protect_using );
136 $filtered = $this->filter_mailto_links( $filtered );
137 $filtered = $this->filter_custom_links( $filtered );
138
139 if ( $convert_plain_to_image ) {
140 $replace_by = 'convert_image';
141 } else {
142 $replace_by = 'use_javascript';
143 }
144
145 if ( $encode_mailtos ) {
146 if ( ! ( function_exists( 'et_fb_enabled' ) && et_fb_enabled() ) ) {
147 $filtered = $this->filter_plain_emails( $filtered, function ( $match ) use ( $self ) {
148 return $self->createProtectedMailto( $match[0], array( 'href' => 'mailto:' . $match[0] ), 'with_javascript' );
149 }, $replace_by);
150 } else {
151 $filtered = $this->filter_plain_emails( $filtered, null, $replace_by );
152 }
153 } else {
154 $filtered = $this->filter_plain_emails( $filtered, null, $replace_by );
155 }
156
157 break;
158 }
159
160 //Revalidate filtered emails that should not be encoded
161 $filtered = $this->tempEncodeAtSymbol( $filtered, true );
162
163 if ( $guard_text_only_zones && ! empty( $text_only_tokens ) ) {
164 $filtered = strtr( $filtered, $text_only_tokens );
165 }
166
167 return $filtered;
168 }
169
170 /**
171 * Pre-encode emails inside text-only HTML zones (<option>, <textarea>, <title>) using
172 * entity encoding and replace each zone with an opaque placeholder. The main filter pass
173 * leaves the placeholders alone; the caller restores them at the end.
174 *
175 * @param string $content
176 * @param array<string, string> $tokens Populated with placeholder => replacement pairs.
177 * @return string Content with text-only zones replaced by placeholder tokens.
178 */
179 private function isolate_text_only_zones( string $content, array &$tokens ): string
180 {
181 $result = preg_replace_callback(
182 '/(<(option|textarea|title)\b[^>]*>)(.*?)(<\/\2\s*>)/is',
183 function ( array $match ) use ( &$tokens ) {
184 $inner_encoded = $this->filter_plain_emails( $match[3], null, 'char_encode', false );
185 $token = "\x00EEB_TEXT_ONLY_" . count( $tokens ) . "\x00";
186 $tokens[ $token ] = $match[1] . $inner_encoded . $match[4];
187
188 return $token;
189 },
190 $content
191 );
192
193 return $result ?? $content;
194 }
195
196 /**
197 * Emails will be replaced by '*protected email*'
198 * @param string $content
199 * @param string|callable $replace_by Optional
200 * @param string $protection_method Optional
201 * @param mixed $show_encoded_check Optional
202 * @return string
203 */
204 public function filter_plain_emails( $content, $replace_by = null, $protection_method = 'default', $show_encoded_check = 'default' )
205 {
206
207 if ( $show_encoded_check === 'default' ) {
208 $show_encoded_check = (bool) $this->getSetting( 'show_encoded_check', true );
209 }
210
211 if ( $replace_by === null ) {
212 $replace_by = (string) $this->getSetting( 'protection_text', true );
213 }
214
215 $self = $this;
216
217 return preg_replace_callback( $this->settings()->get_email_regex(), function ( $matches ) use ( $replace_by, $protection_method, $show_encoded_check, $self ) {
218 // workaround to skip responsive image names containing @
219 $extention = strtolower( $matches[4] );
220 $excludedList = array(
221 '.jpg',
222 '.jpeg',
223 '.png',
224 '.gif',
225 '.svg',
226 '.webp',
227 '.bmp',
228 '.tiff',
229 '.avif',
230 );
231
232 //Added in 2.1.1
233 $excludedList = apply_filters( 'eeb/validate/excluded_image_urls', $excludedList );
234
235 if ( in_array( $extention, $excludedList ) ) {
236 return $matches[0];
237 }
238
239 if ( is_callable( $replace_by ) ) {
240 return call_user_func( $replace_by, $matches, $protection_method );
241 }
242
243 if ( $protection_method === 'char_encode' ) {
244 $protected_return = antispambot( $matches[0] );
245 } elseif ( $protection_method === 'convert_image' ) {
246
247 $image_link = $self->generateEmailImageUrl( $matches[0] );
248 if ( ! empty( $image_link ) ) {
249 $protected_return = '<img src="' . $image_link . '" />';
250 } else {
251 $protected_return = antispambot( $matches[0] );
252 }
253
254 } elseif ( $protection_method === 'use_javascript' ) {
255 $protection_text = (string) $this->getSetting( 'protection_text', true );
256 $protected_return = $this->dynamicJsEmailEncoding( $matches[0], $protection_text );
257 } elseif ( $protection_method === 'use_css' ) {
258 $protection_text = (string) $this->getSetting( 'protection_text', true );
259 // $protected_return = $this->validate()->encoding->encode_email_css( $matches[0], $protection_text );
260 $protected_return = $this->validate()->encoding->encode_email_css( $matches[0] );
261 } elseif ( $protection_method === 'no_encoding' ) {
262 $protected_return = $matches[0];
263 } else {
264 $protected_return = $replace_by;
265 }
266
267 // mark link as successfully encoded (for admin users)
268 if ( current_user_can( $this->getAdminCap( 'frontend-display-security-check' ) ) && $show_encoded_check ) {
269 $protected_return .= $this->getEncodedEmailIcon();
270 }
271
272 return $protected_return;
273 }, $content ) ?? '';
274 }
275
276 /**
277 * Filter passed input fields
278 *
279 * @param string $content
280 * @return string
281 */
282 public function filter_input_fields( string $content, string $encoding_method = 'default' )
283 {
284 $strong_encoding = (bool) $this->getSetting( 'input_strong_protection', true, 'filter_body' );
285
286 $callback_encode_input_fields = function ( $match ) use ( $encoding_method, $strong_encoding ) {
287 $input = $match[0];
288 $email = $match[2];
289
290 //Only allow strong encoding if javascript is supported
291 if ( $encoding_method === 'without_javascript' ) {
292 $strong_encoding = false;
293 }
294
295 return $this->validate()->encoding->encode_input_field( $input, $email, $strong_encoding );
296 };
297
298 $regexpInputField = '/<input([^>]*)value=["\'][\s+]*' . $this->settings()->get_email_regex( true ) . '[\s+]*["\']([^>]*)>/is';
299
300 return preg_replace_callback( $regexpInputField, $callback_encode_input_fields, $content ) ?? '';
301 }
302
303 /**
304 * @param string $content
305 * @param string $protection_method
306 * @return string
307 */
308 public function filter_mailto_links( string $content, ?string $protection_method = null )
309 {
310 $self = $this;
311
312 $callbackEncodeMailtoLinks = function ( $match ) use ( $self, $protection_method ) {
313 $attrs = $this->helper()->parse_html_attributes( $match[1] );
314 return $self->createProtectedMailto( $match[4], $attrs, $protection_method );
315 };
316
317 $regexpMailtoLink = '/<a[\s+]*(([^>]*)href=["\']mailto\:([^>]*)["\' ])>(.*?)<\/a[\s+]*>/is';
318
319 return preg_replace_callback( $regexpMailtoLink, $callbackEncodeMailtoLinks, $content ) ?? '';
320 }
321
322 /**
323 * @param string $content
324 * @param string $protection_method
325 * @return string
326 */
327 public function filter_custom_links( string $content, ?string $protection_method = null )
328 {
329 $self = $this;
330 $custom_href_attr = (string) $this->getSetting( 'custom_href_attr', true );
331
332 if ( ! empty( $custom_href_attr ) ) {
333 $custom_attr_list = explode( ',', $custom_href_attr );
334 foreach ( $custom_attr_list as $s_attr ) {
335 $attr_name = trim( $s_attr );
336
337 $callbackEncodeCustomLinks = function ( $match ) use ( $self, $protection_method ) {
338 $attrs = shortcode_parse_atts( $match[1] );
339 return $self->createProtectedHrefAtt( $match[4], $attrs, $protection_method );
340 };
341
342 $regexpMailtoLink = '/<a[\s+]*(([^>]*)href=["\']' . addslashes( $attr_name ) . '\:([^>]*)["\' ])>(.*?)<\/a[\s+]*>/is';
343
344 $content = preg_replace_callback( $regexpMailtoLink, $callbackEncodeCustomLinks, $content ) ?? '';
345 }
346 }
347
348 return $content;
349 }
350
351 /**
352 * Emails will be replaced by '*protected email*'
353 *
354 * @param string $content
355 * @param string $protection_type
356 * @return string
357 */
358 public function filter_rss( string $content, ?string $protection_type )
359 {
360
361 if ( $protection_type === 'strong_method' ) {
362 $filtered = $this->filter_plain_emails( $content );
363 } else {
364 $filtered = $this->filter_plain_emails( $content, null, 'char_encode' );
365 }
366
367 return $filtered;
368 }
369
370 /**
371 * Filter plain emails using soft attributes
372 *
373 * @param string $content - the content that should be soft filtered
374 * @param string $protection_method - The method (E.g. char_encode)
375 * @return string
376 */
377 public function filter_soft_attributes( string $content, string $protection_method )
378 {
379 $soft_attributes = (array) $this->settings()->get_soft_attribute_regex();
380
381 foreach ( $soft_attributes as $ident => $regex ) {
382
383 // $attributes = array();
384 preg_match_all( $regex, $content, $attributes );
385
386 // if ( is_array( $attributes ) && isset( $attributes[0] ) ) {
387 foreach ( $attributes[0] as $single ) {
388
389 if ( empty( $single ) ) {
390 continue;
391 }
392
393 $content = str_replace( $single, $this->filter_plain_emails( $single, null, $protection_method, false ), $content );
394 }
395 // }
396
397 }
398
399 return $content;
400 }
401
402 /**
403 * Filter plain emails using soft dom attributes
404 *
405 * @param string $content - the content that should be soft filtered
406 * @param string $protection_method - The method (E.g. char_encode)
407 * @return string
408 */
409 public function filter_soft_dom_attributes( $content, $protection_method )
410 {
411 $content = (string) $content;
412
413 $no_script_tags = (bool) $this->getSetting( 'no_script_tags', true, 'filter_body' );
414 $no_attribute_validation = (bool) $this->getSetting( 'no_attribute_validation', true, 'filter_body' );
415
416 if ( $content !== '' ) {
417
418 if ( class_exists( 'DOMDocument' ) ) {
419 $dom = new DOMDocument();
420
421 // DOMDocument uses an HTML4 parser, so modern/third-party markup
422 // (inline SVG such as <defs>/<linearGradient>, HTML5 elements) makes
423 // libxml emit "Tag X invalid in Entity" warnings. The page still
424 // renders fine, so route those into libxml's internal buffer instead
425 // of PHP's error stream — otherwise Query Monitor (which surfaces even
426 // @-silenced errors) and debug.log fill up with harmless noise. Restore
427 // the previous state afterwards so we don't affect other code.
428 $libxml_previous = libxml_use_internal_errors( true );
429 $dom->loadHTML( $content );
430 libxml_clear_errors();
431 libxml_use_internal_errors( $libxml_previous );
432
433 //Filter html attributes
434 if ( ! $no_attribute_validation ) {
435 $allNodes = $dom->getElementsByTagName('*');
436 foreach ( $allNodes as $snote ) {
437 if ( $snote->hasAttributes() ) {
438 foreach ( $snote->attributes as $attr ) {
439 if ( $attr->nodeName == 'href' || $attr->nodeName == 'src' ) {
440 continue;
441 }
442
443 if ( $attr->nodeValue !== null && strpos( $attr->nodeValue, '@' ) !== false ) {
444 // $single_tags = array();
445 preg_match_all( '/' . $attr->nodeName . '=["\']([^"]*)["\']/i', $content, $single_tags );
446
447 // if ( is_array( $single_tags ) && isset( $single_tags[0] ) ) {
448 foreach ( $single_tags[0] as $single ) {
449
450 if ( empty( $single ) ) {
451 continue;
452 }
453
454 $content = str_replace( $single, $this->filter_plain_emails( $single, null, $protection_method, false ), $content );
455 }
456 // }
457
458 }
459 }
460 }
461 }
462 }
463
464 //Keep for now
465 //Soft-encode scripts
466 // $script = $dom->getElementsByTagName('script');
467 // if ( ! empty( $script ) ) {
468 // $scripts_encoded = true;
469
470 // if ( ! $no_script_tags ) {
471 // foreach( $script as $item ) {
472 // $content = str_replace( $item->nodeValue, $this->filter_plain_emails( $item->nodeValue, null, $protection_method, false ), $content );
473 // }
474 // } else {
475 // foreach( $script as $item ) {
476 // $content = str_replace( $item->nodeValue, $this->temp_encode_at_symbol( $item->nodeValue ), $content );
477 // }
478 // }
479 // }
480
481 }
482
483 //Validate script tags for better encoding
484 $pattern = '/<script\b[^>]*>(.*?)<\/script>/is';
485
486 // On very large pages — or hosts with a low pcre.backtrack_limit — this
487 // lazy regex can bail out with PREG_BACKTRACK_LIMIT_ERROR and return no
488 // matches. That silently disables @-masking inside <script> blocks, so the
489 // encoder later injects a raw </script> into inline JSON data blobs (e.g. a
490 // page builder's FAQ/search payload), prematurely closing the host script
491 // and dumping its contents as visible text below the page. Fall back to a
492 // linear, backtrack-free scan so the guard keeps working on those pages.
493 if ( preg_match_all( $pattern, $content, $matches ) === false || preg_last_error() !== PREG_NO_ERROR ) {
494 $matches = $this->extract_script_blocks( $content );
495 }
496
497 if ( ! empty( $matches[1] ) ) {
498 if ( ! $no_script_tags ) {
499 foreach ( $matches[1] as $key => $item ) {
500
501 //Don't do anything if something doesn't add up
502 if ( ! isset( $matches[0][ $key ] ) ) {
503 continue;
504 }
505
506 $org_script = $matches[0][ $key ];
507
508 //Only encode emails when a CDATA is given to not cause any break within the scripts
509 if ( strpos( $item, '<![CDATA' ) !== false ) {
510 $validated_script = str_replace( $item, $this->filter_plain_emails( $item, null, $protection_method, false ), $org_script );
511 } else {
512 $validated_script = str_replace( $item, $this->tempEncodeAtSymbol( $item ), $org_script );
513 }
514
515 $content = str_replace( $org_script, $validated_script, $content );
516 }
517 } else {
518 foreach ( $matches[1] as $key => $item ) {
519
520 //Don't do anything if something doesn't add up
521 if ( ! isset( $matches[0][ $key ] ) ) {
522 continue;
523 }
524
525 $org_script = $matches[0][ $key ];
526 $validated_script = str_replace( $item, $this->tempEncodeAtSymbol( $item ), $org_script );
527
528 $content = str_replace( $org_script, $validated_script, $content );
529 }
530 }
531 }
532
533 }
534
535 return $content;
536 }
537
538 /**
539 * Linear, backtrack-free extraction of <script>…</script> blocks.
540 *
541 * Mirrors the capture shape of preg_match_all( '/<script\b[^>]*>(.*?)<\/script>/is' )
542 * — index 0 holds the full tags, index 1 the bodies — but without the lazy
543 * quantifier, so it cannot hit pcre.backtrack_limit on very large pages. Used as a
544 * fallback when that regex errors out (see filter_soft_dom_attributes).
545 *
546 * @param string $content
547 * @return array{0: array<int, string>, 1: array<int, string>}
548 */
549 private function extract_script_blocks( string $content )
550 {
551 $full = array();
552 $bodies = array();
553 $length = strlen( $content );
554 $offset = 0;
555
556 while ( ( $open = stripos( $content, '<script', $offset ) ) !== false ) {
557
558 // Enforce the regex's \b: the char after "script" must be a non-word char,
559 // so tags like <scripting> are not treated as <script>.
560 $after = $open + 7; // strlen( '<script' )
561 $boundary = ( $after < $length ) ? $content[ $after ] : '';
562 if ( $boundary !== '' && ( ctype_alnum( $boundary ) || $boundary === '_' ) ) {
563 $offset = $after;
564 continue;
565 }
566
567 // End of the opening tag ( first '>' — matches the regex's [^>]*> ).
568 $tag_end = strpos( $content, '>', $open );
569 if ( $tag_end === false ) {
570 break;
571 }
572 $body_start = $tag_end + 1;
573
574 // First literal </script> ( matches the lazy .*? up to <\/script> ).
575 $close = stripos( $content, '</script>', $body_start );
576 if ( $close === false ) {
577 break;
578 }
579 $full_end = $close + 9; // strlen( '</script>' )
580
581 $full[] = substr( $content, $open, $full_end - $open );
582 $bodies[] = substr( $content, $body_start, $close - $body_start );
583
584 $offset = $full_end;
585 }
586
587 return array( $full, $bodies );
588 }
589 }
590