Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
AntonV1211 marked this conversation as resolved.
}
}
$result .= $part;
}

return $result;
}

/**
Expand Down Expand Up @@ -737,7 +748,7 @@ protected function prepareTitleForBufferMode($title)
return $title;
}

return $this->removeDuplicateContactsOutsideShortcodes($title);
return wp_kses_post($this->removeDuplicateContactsOutsideShortcodes($title));
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down
28 changes: 22 additions & 6 deletions lib/Cleantalk/Common/ContactsEncoder/assets/contacts_encoder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -348,7 +344,7 @@ class ContactsEncoder {
encodedEmailTextInsideMailto = row.decoded_email;
}
});
el.innerHTML = encodedEmailTextInsideMailto;
el.textContent = encodedEmailTextInsideMailto;
});
} else {
encodedNodes[i].classList.add('no-blur');
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
'<a href="https:javascript:alert(1)">',
['https:'],
'<a href="https:javascript:alert(1)">',
],
'text node stripped' => ['<a href="https:x">https:text</a>', ['https:'], '<a href="https:x">text</a>'],
'comment untouched' => ['<!-- https: -->https:', ['https:'], '<!-- https: -->'],
'contact is literal, not regex' => ['a+b a.b', ['a.b'], 'a+b '],
];
}

public function testRemoveDuplicatesDoesNotForgeJavascriptScheme(): void
{
$title = '<a href="https:javascript:alert(document.domain)">'
. '[apbct_skip_encoding]https:[/apbct_skip_encoding]marker</a>';

$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('/<a\b[^>]*\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' => ['<a href="https:javascript:alert(document.domain)">' . $skip . 'marker</a>'],
'> in double-quoted attribute' => ['<a title="a>b" href="https:javascript:alert(1)">x</a>' . $skip],
'> in single-quoted attribute' => ["<a title='a>b' href='https:javascript:alert(1)'>x</a>" . $skip],
];
}

public function testBufferModeTitleDropsScriptTags(): void
{
$title = '[apbct_skip_encoding]x@y.com[/apbct_skip_encoding]<script>alert(1)</script>';

$this->assertStringNotContainsString('<script', $this->callProtected('prepareTitleForBufferMode', [$title]));
}
}
Loading