PluginProbe ʕ •ᴥ•ʔ
Email Encoder – Protect Email Addresses and Phone Numbers / 2.5.1
Email Encoder – Protect Email Addresses and Phone Numbers v2.5.1
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 2 months ago Encoding.php 2 months ago Filters.php 6 days ago Validate.php 6 months ago
Filters.php
579 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 @$dom->loadHTML($content);
421
422 //Filter html attributes
423 if ( ! $no_attribute_validation ) {
424 $allNodes = $dom->getElementsByTagName('*');
425 foreach ( $allNodes as $snote ) {
426 if ( $snote->hasAttributes() ) {
427 foreach ( $snote->attributes as $attr ) {
428 if ( $attr->nodeName == 'href' || $attr->nodeName == 'src' ) {
429 continue;
430 }
431
432 if ( $attr->nodeValue !== null && strpos( $attr->nodeValue, '@' ) !== false ) {
433 // $single_tags = array();
434 preg_match_all( '/' . $attr->nodeName . '=["\']([^"]*)["\']/i', $content, $single_tags );
435
436 // if ( is_array( $single_tags ) && isset( $single_tags[0] ) ) {
437 foreach ( $single_tags[0] as $single ) {
438
439 if ( empty( $single ) ) {
440 continue;
441 }
442
443 $content = str_replace( $single, $this->filter_plain_emails( $single, null, $protection_method, false ), $content );
444 }
445 // }
446
447 }
448 }
449 }
450 }
451 }
452
453 //Keep for now
454 //Soft-encode scripts
455 // $script = $dom->getElementsByTagName('script');
456 // if ( ! empty( $script ) ) {
457 // $scripts_encoded = true;
458
459 // if ( ! $no_script_tags ) {
460 // foreach( $script as $item ) {
461 // $content = str_replace( $item->nodeValue, $this->filter_plain_emails( $item->nodeValue, null, $protection_method, false ), $content );
462 // }
463 // } else {
464 // foreach( $script as $item ) {
465 // $content = str_replace( $item->nodeValue, $this->temp_encode_at_symbol( $item->nodeValue ), $content );
466 // }
467 // }
468 // }
469
470 }
471
472 //Validate script tags for better encoding
473 $pattern = '/<script\b[^>]*>(.*?)<\/script>/is';
474
475 // On very large pages — or hosts with a low pcre.backtrack_limit — this
476 // lazy regex can bail out with PREG_BACKTRACK_LIMIT_ERROR and return no
477 // matches. That silently disables @-masking inside <script> blocks, so the
478 // encoder later injects a raw </script> into inline JSON data blobs (e.g. a
479 // page builder's FAQ/search payload), prematurely closing the host script
480 // and dumping its contents as visible text below the page. Fall back to a
481 // linear, backtrack-free scan so the guard keeps working on those pages.
482 if ( preg_match_all( $pattern, $content, $matches ) === false || preg_last_error() !== PREG_NO_ERROR ) {
483 $matches = $this->extract_script_blocks( $content );
484 }
485
486 if ( ! empty( $matches[1] ) ) {
487 if ( ! $no_script_tags ) {
488 foreach ( $matches[1] as $key => $item ) {
489
490 //Don't do anything if something doesn't add up
491 if ( ! isset( $matches[0][ $key ] ) ) {
492 continue;
493 }
494
495 $org_script = $matches[0][ $key ];
496
497 //Only encode emails when a CDATA is given to not cause any break within the scripts
498 if ( strpos( $item, '<![CDATA' ) !== false ) {
499 $validated_script = str_replace( $item, $this->filter_plain_emails( $item, null, $protection_method, false ), $org_script );
500 } else {
501 $validated_script = str_replace( $item, $this->tempEncodeAtSymbol( $item ), $org_script );
502 }
503
504 $content = str_replace( $org_script, $validated_script, $content );
505 }
506 } else {
507 foreach ( $matches[1] as $key => $item ) {
508
509 //Don't do anything if something doesn't add up
510 if ( ! isset( $matches[0][ $key ] ) ) {
511 continue;
512 }
513
514 $org_script = $matches[0][ $key ];
515 $validated_script = str_replace( $item, $this->tempEncodeAtSymbol( $item ), $org_script );
516
517 $content = str_replace( $org_script, $validated_script, $content );
518 }
519 }
520 }
521
522 }
523
524 return $content;
525 }
526
527 /**
528 * Linear, backtrack-free extraction of <script>…</script> blocks.
529 *
530 * Mirrors the capture shape of preg_match_all( '/<script\b[^>]*>(.*?)<\/script>/is' )
531 * — index 0 holds the full tags, index 1 the bodies — but without the lazy
532 * quantifier, so it cannot hit pcre.backtrack_limit on very large pages. Used as a
533 * fallback when that regex errors out (see filter_soft_dom_attributes).
534 *
535 * @param string $content
536 * @return array{0: array<int, string>, 1: array<int, string>}
537 */
538 private function extract_script_blocks( string $content )
539 {
540 $full = array();
541 $bodies = array();
542 $length = strlen( $content );
543 $offset = 0;
544
545 while ( ( $open = stripos( $content, '<script', $offset ) ) !== false ) {
546
547 // Enforce the regex's \b: the char after "script" must be a non-word char,
548 // so tags like <scripting> are not treated as <script>.
549 $after = $open + 7; // strlen( '<script' )
550 $boundary = ( $after < $length ) ? $content[ $after ] : '';
551 if ( $boundary !== '' && ( ctype_alnum( $boundary ) || $boundary === '_' ) ) {
552 $offset = $after;
553 continue;
554 }
555
556 // End of the opening tag ( first '>' — matches the regex's [^>]*> ).
557 $tag_end = strpos( $content, '>', $open );
558 if ( $tag_end === false ) {
559 break;
560 }
561 $body_start = $tag_end + 1;
562
563 // First literal </script> ( matches the lazy .*? up to <\/script> ).
564 $close = stripos( $content, '</script>', $body_start );
565 if ( $close === false ) {
566 break;
567 }
568 $full_end = $close + 9; // strlen( '</script>' )
569
570 $full[] = substr( $content, $open, $full_end - $open );
571 $bodies[] = substr( $content, $body_start, $close - $body_start );
572
573 $offset = $full_end;
574 }
575
576 return array( $full, $bodies );
577 }
578 }
579