diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/ExcludedEncodeContentSC.php
index fe2a46ed2..f18f19ef4 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,11 +902,39 @@ 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 = $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.
*
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
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('