Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"wp-cli/wp-cli-bundle": "*",
"mockery/mockery": "*",
"cleantalk/apbct-installer": "*",
"cleantalk/contacts-encoder": "2.0.18",
"cleantalk/contacts-encoder": "2.0.18.2",
"cleantalk/rate-limiter": "*"
},
"scripts": {
Expand Down
90 changes: 73 additions & 17 deletions lib/Cleantalk/Common/ContactsEncoder/ContactsEncoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,22 +88,11 @@ abstract class ContactsEncoder
*/
protected $global_tel_pattern;

/**
* @var array
* @psalm-suppress PossiblyUnusedProperty
*/
protected $aria_matches = array();

/**
* @var array Placeholder => original aria-label for restore
*/
protected $aria_placeholders = array();

/**
* @var int Counter for unique aria-label placeholders
*/
protected $aria_index = 0;

/**
* Attributes with possible email-like content to drop from the content to avoid unnecessary encoding.
* Key is a tag we want to find, value is an attribute with email to drop.
Expand Down Expand Up @@ -264,7 +253,9 @@ public function modifyContent($content, $skip_exclusions = false)
}

// modify content to prevent aria-label replaces by hiding it
$content = $this->handleAriaLabelContent($content);
if ( $this->do_encode_emails || $this->do_encode_phones ) {
$content = $this->handleAriaLabelContent($content);
}

// will use this in regexp callback
$this->temp_content = $content;
Expand Down Expand Up @@ -904,17 +895,17 @@ private static function dropAttributesContainEmail($content, $tags)
private function handleAriaLabelContent($content, $reverse = false)
{
if ( !$reverse ) {
$this->aria_matches = array();
$this->aria_placeholders = array();
$this->aria_index = 0;
if ( !$this->isSecureAriaLabelPlaceholderAvailable() ) {
return $content;
}
return preg_replace_callback($this->aria_regex, array($this, 'replaceAriaLabelWithPlaceholder'), $content);
}
Comment thread
svfcode marked this conversation as resolved.
if ( !empty($this->aria_placeholders) ) {
foreach ($this->aria_placeholders as $placeholder => $original) {
$content = str_replace($placeholder, $original, $content);
$content = $this->restoreAriaLabelPlaceholder($content, $placeholder, $original);
}
$this->aria_placeholders = array();
$this->aria_index = 0;
}
return $content;
}
Expand All @@ -930,8 +921,73 @@ private function replaceAriaLabelWithPlaceholder($matches)
return '';
}
$original = $matches[0];
$placeholder = 'ct_temp_aria_' . $this->aria_index++;
$placeholder = $this->generateAriaLabelPlaceholder();
if ( $placeholder === null ) {
return $original;
}
$this->aria_placeholders[$placeholder] = $original;
return $placeholder;
}

/**
* Whether a cryptographically secure placeholder can be generated.
*
* @return bool
*/
private function isSecureAriaLabelPlaceholderAvailable()
{
if ( function_exists('random_bytes') ) {
return true;
}

return function_exists('openssl_random_pseudo_bytes');
}

/**
* Build an unguessable placeholder so attacker-controlled content cannot collide with it.
*
* @return string|null Null when no secure entropy source is available.
*/
private function generateAriaLabelPlaceholder()
{
if ( function_exists('random_bytes') ) {
try {
$bytes = random_bytes(16);
if ( is_string($bytes) && strlen($bytes) === 16 ) {
return '%%APBCT_ARIA_' . bin2hex($bytes) . '%%';
}
} catch ( \Exception $e ) {
// fall through to openssl
}
}

if ( function_exists('openssl_random_pseudo_bytes') ) {
$crypto_strong = false;
$bytes = openssl_random_pseudo_bytes(16, $crypto_strong);
if ( $crypto_strong && is_string($bytes) && strlen($bytes) === 16 ) {
return '%%APBCT_ARIA_' . bin2hex($bytes) . '%%';
}
}

return null;
}
Comment thread
svfcode marked this conversation as resolved.
Comment thread
svfcode marked this conversation as resolved.

/**
* Restore a single aria-label placeholder at its first occurrence only.
*
* @param string $content
* @param string $placeholder
* @param string $original
*
* @return string
*/
private function restoreAriaLabelPlaceholder($content, $placeholder, $original)
{
$pos = strpos($content, $placeholder);
if ( $pos === false ) {
return $content;
}

return substr($content, 0, $pos) . $original . substr($content, $pos + strlen($placeholder));
}
}
49 changes: 49 additions & 0 deletions tests/ApbctWP/ContactsEncoder/TestContactsEncoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,55 @@ public function testModifyContentSkipsEmailsInsideOptionTags()
$this->assertStringContainsString('apbct-email-encoder', $result);
}

/**
* aria-label values must survive email encoding round-trip intact.
*/
public function testModifyContentPreservesAriaLabelWithEmail()
{
$email = 'info@example.com';
$content = '<button aria-label="Contact us at ' . $email . '">Click</button>';

$result = $this->contacts_encoder->modifyContent($content);

$this->assertStringContainsString('aria-label="Contact us at ' . $email . '"', $result);
$this->assertStringNotContainsString('%%APBCT_ARIA_', $result);
$this->assertStringNotContainsString('ct_temp_aria_', $result);
}

/**
* CVE-2026-77830: planted ct_temp_aria_0 must not be rewritten during aria-label restore.
*/
public function testModifyContentDoesNotRestorePlantedCtTempAriaToken()
{
$payload = '<blockquote cite=" aria-label=" > <a title="test">test</a></blockquote>'
. '<a >ct_temp_aria_0</a>'
. '<a title="style=display:block;content-visibility:auto oncontentvisibilityautostatechange=alert(2026)//">test</a>';

$result = $this->contacts_encoder->modifyContent($payload);

$this->assertStringContainsString('ct_temp_aria_0', $result);
$this->assertNotRegExp('/>\s*aria-label\s*=/', $result);
}

/**
* CVE-2026-77830: Wordfence PoC must not produce aria-label markup breakout after encoding.
*/
public function testModifyContentWordfenceAriaLabelXssPayloadDoesNotBreakOut()
{
$payload = '<blockquote cite=" aria-label=" > <a title="test">test</a></blockquote>' . "\n"
. '<a >ct_temp_aria_0</a>'
. '<a title="style=display:block;content-visibility:auto '
. 'oncontentvisibilityautostatechange=alert(2026)//">test</a>';

$result = $this->contacts_encoder->modifyContent($payload);

$planted_token_preserved = strpos($result, 'ct_temp_aria_0') !== false;
$breakout_injected = (bool) preg_match('/>\s*aria-label\s*=/', $result);

$this->assertTrue($planted_token_preserved, 'Planted ct_temp_aria_0 token must survive encoder round-trip.');
$this->assertFalse($breakout_injected, 'Encoder must not inject aria-label markup via token substitution.');
}

public function testEncodingPhoneNumbers()
{
global $apbct;
Expand Down
Loading