From b6cca1a4d743993e95895e777cbedd0d8160a252 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Mon, 12 Oct 2026 15:48:21 +0700 Subject: [PATCH 1/4] Fix. ContactEncoder. Edit to the tag attribute definition --- .../Shortcodes/ExcludedEncodeContentSC.php | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php index fe2a46ed2..71aae54fe 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php @@ -642,12 +642,23 @@ protected function stripProtectedContactsFromPlainText($text, array $contacts) return ''; } - foreach ( $contacts as $contact ) { - $quoted = preg_quote($contact, '/'); - $text = preg_replace('/' . $quoted . '/', '', $text); + $parts = wp_html_split($text); + if ( ! is_array($parts) ) { + return $text; } - return $text; + $result = ''; + foreach ( $parts as $part ) { + if ( $part !== '' && $part[0] !== '<' ) { + foreach ( $contacts as $contact ) { + $quoted = preg_quote($contact, '/'); + $part = preg_replace('/' . $quoted . '/', '', $part); + } + } + $result .= $part; + } + + return $result; } /** @@ -737,7 +748,7 @@ protected function prepareTitleForBufferMode($title) return $title; } - return $this->removeDuplicateContactsOutsideShortcodes($title); + return wp_kses_post($this->removeDuplicateContactsOutsideShortcodes($title)); } /** @@ -891,6 +902,8 @@ public function processTitleString($title, $strip_html = true) if ( $strip_html ) { $result = wp_strip_all_tags($result); $result = trim(preg_replace('/\s+/', ' ', $result), " \n\r\t\v\x00"); + } else { + $result = wp_kses_post($result); } return $result; From 5eb7ec6fae07a6fdac40642a6472dac722bae7ee Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Mon, 12 Oct 2026 18:43:05 +0700 Subject: [PATCH 2/4] Dev. Code. Unit tests --- .../ExcludedEncodeContentSCTest.php | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php b/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php index 8c8bc8c66..b0f2495c6 100644 --- a/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php +++ b/tests/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSCTest.php @@ -463,4 +463,106 @@ public function testChangeContentBeforeEncoderModifySkipsShortcodeWithHtmlTags() $this->assertSame($content, $result); $this->assertCount(0, $this->exclude_content_sc->shortcode_replacements); } + + /** + * @param string $method + * @param array $args + * + * @return mixed + */ + private function callProtected($method, array $args) + { + $reflection = new \ReflectionMethod(ExcludedEncodeContentSC::class, $method); + $reflection->setAccessible(true); + + return $reflection->invokeArgs($this->exclude_content_sc, $args); + } + + /** + * Contacts must be stripped only from text nodes, never from tag attributes. + * + * @dataProvider stripProtectedContactsProvider + */ + public function testStripProtectedContactsFromPlainText($text, array $contacts, $expected): void + { + $this->assertSame( + $expected, + $this->callProtected('stripProtectedContactsFromPlainText', [$text, $contacts]) + ); + } + + public function stripProtectedContactsProvider(): array + { + return [ + 'empty text' => ['', ['a'], ''], + 'attribute untouched' => [ + '', + ['https:'], + '', + ], + 'text node stripped' => ['https:text', ['https:'], 'text'], + 'comment untouched' => ['https:', ['https:'], ''], + 'contact is literal, not regex' => ['a+b a.b', ['a.b'], 'a+b '], + ]; + } + + public function testRemoveDuplicatesDoesNotForgeJavascriptScheme(): void + { + $title = '' + . '[apbct_skip_encoding]https:[/apbct_skip_encoding]marker'; + + $this->assertSame($title, $this->callProtected('removeDuplicateContactsOutsideShortcodes', [$title])); + } + + public function testRemoveDuplicatesStillStripsDuplicateContactFromText(): void + { + $title = 'Mail me at a@b.com [apbct_skip_encoding]a@b.com[/apbct_skip_encoding]'; + + $this->assertSame( + 'Mail me at [apbct_skip_encoding]a@b.com[/apbct_skip_encoding]', + $this->callProtected('removeDuplicateContactsOutsideShortcodes', [$title]) + ); + } + + /** + * End-to-end: final title output must not contain a javascript: link in either mode. + * + * @dataProvider titleXssPayloadProvider + */ + public function testFilterTheTitleDoesNotOutputJavascriptHref($title): void + { + global $apbct; + + $previous_buffer = $apbct->settings['data__email_decoder_buffer']; + + try { + foreach ([true, false] as $buffer_mode) { + $apbct->settings['data__email_decoder_buffer'] = $buffer_mode; + + $result = $this->exclude_content_sc->filterTheTitle($title); + + $this->assertNotRegExp('/]*\bhref\s*=\s*["\']?\s*javascript:/i', $result); + } + } finally { + $apbct->settings['data__email_decoder_buffer'] = $previous_buffer; + } + } + + public function titleXssPayloadProvider(): array + { + $skip = '[apbct_skip_encoding]https:[/apbct_skip_encoding]'; + + return [ + 'scheme forged by strip' => ['' . $skip . 'marker'], + '> in double-quoted attribute' => ['x' . $skip], + '> in single-quoted attribute' => ["x" . $skip], + ]; + } + + public function testBufferModeTitleDropsScriptTags(): void + { + $title = '[apbct_skip_encoding]x@y.com[/apbct_skip_encoding]'; + + $this->assertStringNotContainsString('callProtected('prepareTitleForBufferMode', [$title])); + } } From 971da4c82226ff3dd1b58f6fa60de98eccad9752 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Mon, 12 Oct 2026 19:09:38 +0700 Subject: [PATCH 3/4] Dev. Code. Sync common lib --- .../assets/contacts_encoder.js | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/Cleantalk/Common/ContactsEncoder/assets/contacts_encoder.js b/lib/Cleantalk/Common/ContactsEncoder/assets/contacts_encoder.js index 55758a9a1..315e5afe2 100644 --- a/lib/Cleantalk/Common/ContactsEncoder/assets/contacts_encoder.js +++ b/lib/Cleantalk/Common/ContactsEncoder/assets/contacts_encoder.js @@ -334,11 +334,7 @@ class ContactsEncoder { continue; } let encodedEmail = encodedNodes[i].href.replace(linkTypePrefix, ''); - let baseElementContent = encodedNodes[i].innerHTML; - encodedNodes[i].innerHTML = baseElementContent.replace( - encodedEmail, - currentResultData.decoded_email, - ); + this.replaceInTextNodes(encodedNodes[i], encodedEmail, currentResultData.decoded_email); encodedNodes[i].href = linkTypePrefix + currentResultData.decoded_email; encodedNodes[i].querySelectorAll('span.apbct-email-encoder').forEach((el) => { @@ -348,7 +344,7 @@ class ContactsEncoder { encodedEmailTextInsideMailto = row.decoded_email; } }); - el.innerHTML = encodedEmailTextInsideMailto; + el.textContent = encodedEmailTextInsideMailto; }); } else { encodedNodes[i].classList.add('no-blur'); @@ -371,6 +367,26 @@ class ContactsEncoder { } } + /** + * Replace the first occurrence of search in element text only, never in markup or attributes. + * @param {HTMLElement} element + * @param {string} search + * @param {string} replacement + */ + replaceInTextNodes(element, search, replacement) { + if (!search) { + return; + } + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { + if (node.nodeValue.indexOf(search) !== -1) { + node.nodeValue = node.nodeValue.replace(search, () => replacement); + return; + } + } + } + /** * @param {mixed} response * @param {mixed} targetElement From d05b8d0ead8b474c17f9ff7053eeb50f640b4e0f Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Mon, 12 Oct 2026 19:20:17 +0700 Subject: [PATCH 4/4] Dev. Code. Fix copilot review --- .../Shortcodes/ExcludedEncodeContentSC.php | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php index 71aae54fe..f18f19ef4 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php @@ -903,12 +903,38 @@ public function processTitleString($title, $strip_html = true) $result = wp_strip_all_tags($result); $result = trim(preg_replace('/\s+/', ' ', $result), " \n\r\t\v\x00"); } else { - $result = wp_kses_post($result); + $result = $this->ksesEncodedMarkup($result); } return $result; } + /** + * wp_kses_post() keeping encoder attributes: WP < 5.0 strips data-* attributes. + * + * @param string $html + * + * @return string + */ + protected function ksesEncodedMarkup($html) + { + $allowed_html = wp_kses_allowed_html('post'); + $encoder_attributes = array( + 'data-original-string' => true, + 'class' => true, + 'title' => true, + ); + + foreach ( array('span', 'a') as $tag ) { + $allowed_html[$tag] = array_merge( + isset($allowed_html[$tag]) ? $allowed_html[$tag] : array(), + $encoder_attributes + ); + } + + return wp_kses($html, $allowed_html); + } + /** * Remove skip-encoding shortcodes before slug generation. *