Skip to content
Merged
106 changes: 89 additions & 17 deletions ContactsEncoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,22 +88,11 @@ 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 @@ -269,7 +258,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);
}

Comment thread
svfcode marked this conversation as resolved.
// will use this in regexp callback
$this->temp_content = $content;
Expand Down Expand Up @@ -810,17 +801,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);
}
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 @@ -836,8 +827,89 @@ 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()
{
return function_exists('random_bytes') || 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()
{
$bytes = $this->getSecureRandomBytes(16);
if ( !is_string($bytes) || strlen($bytes) !== 16 ) {
return null;
}

return '%%APBCT_ARIA_' . bin2hex($bytes) . '%%';
}

/**
* @param int $length
*
* @return string|null
*/
private function getSecureRandomBytes($length)
{
if ( !is_int($length) || $length < 1 ) {
return null;
}

if ( function_exists('random_bytes') ) {
try {
// phpcs:ignore PHPCompatibility.FunctionUse.NewFunctions.random_bytesFound
$bytes = random_bytes($length);
if ( strlen($bytes) === $length ) {
return $bytes;
}
} catch ( \Exception $e ) {
// Fall through to OpenSSL.
}
}

if ( function_exists('openssl_random_pseudo_bytes') ) {
$crypto_strong = false;
$bytes = openssl_random_pseudo_bytes($length, $crypto_strong);
if ( $crypto_strong && is_string($bytes) && strlen($bytes) === $length ) {
return $bytes;
}
}
Comment thread
Copilot marked this conversation as resolved.

return null;
}

/**
* 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));
}
}
185 changes: 172 additions & 13 deletions Helper/ContactsEncoderHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,25 @@ class ContactsEncoderHelper
* @var array[]
*/
private $attribute_exclusions_signs = array(
'input' => array('placeholder', 'value'),
'input' => array('placeholder', 'value', 'data-mask'),
'option' => array('value'),
'sc-customer-email' => array('placeholder', 'value'),
'img' => array('alt', 'title'),
'div' => array('data-et-multi-view'),
);
Comment thread
svfcode marked this conversation as resolved.

/**
* Runtime map of tag => attribute names. Null means use the built-in defaults.
* @var array[]|null
*/
private $runtime_attribute_exclusions_signs;

/**
* Flat list of HTML attribute names to skip encoding in, regardless of tag.
* @var string[]
*/
private $attribute_exclusions_list = array();

/**
* Checking if the string contains mailto: link
*
Expand Down Expand Up @@ -135,31 +147,178 @@ public function isInsideScriptTag($email, $content)
return ($pos > $last_script_start && $pos < $script_end);
}

/**
* Built-in tag => attribute map. Returned as a copy so callers can mutate it safely.
*
* @return array[]
* @psalm-suppress PossiblyUnusedMethod
*/
public function getDefaultAttributeExclusionsSigns()
{
$copy = array();
foreach ( $this->attribute_exclusions_signs as $tag => $attributes ) {
$copy[$tag] = is_array($attributes) ? array_values($attributes) : $attributes;
}

return $copy;
}

/**
* Replace the working tag => attribute map (e.g. after a host-app filter).
*
* @param array $map
* @return void
* @psalm-suppress PossiblyUnusedMethod
*/
public function setAttributeExclusionsMap(array $map)
{
$this->runtime_attribute_exclusions_signs = $map;
}

/**
* Merge extra attribute names for a tag into the working map.
*
* @param string $tag
* @param string[] $attributes
* @return void
* @psalm-suppress PossiblyUnusedMethod
*/
public function addAttributeExclusions($tag, array $attributes)
{
if ( ! is_string($tag) || $tag === '' ) {
return;
}

$map = $this->getWorkingAttributeExclusionsSigns();
if ( ! isset($map[$tag]) || ! is_array($map[$tag]) ) {
$map[$tag] = array();
}

foreach ( $attributes as $attribute ) {
if ( is_string($attribute) && $attribute !== '' && ! in_array($attribute, $map[$tag], true) ) {
$map[$tag][] = $attribute;
}
}

$this->runtime_attribute_exclusions_signs = $map;
}

/**
* Replace the flat list of attribute names skipped on any tag.
*
* @param array $names
* @return void
* @psalm-suppress PossiblyUnusedMethod
*/
public function setAttributeNames(array $names)
{
$this->attribute_exclusions_list = $this->sanitizeAttributeNames($names);
}

/**
* Append attribute names skipped on any tag.
*
* @param string[] $names
* @return void
* @psalm-suppress PossiblyUnusedMethod
*/
public function addAttributeNames(array $names)
{
foreach ( $this->sanitizeAttributeNames($names) as $attribute ) {
if ( ! in_array($attribute, $this->attribute_exclusions_list, true) ) {
$this->attribute_exclusions_list[] = $attribute;
}
}
}

/**
* Check if email is placed in the tag that has attributes of exclusions.
*
* @param string $email_match - email
* @param string $temp_content - email
* @return bool
*/
public function hasAttributeExclusions($email_match, $temp_content)
{
$email_match = preg_quote($email_match);
foreach ( $this->attribute_exclusions_signs as $tag => $array_of_attributes ) {
if ( ! is_string($email_match) || $email_match === '' || ! is_string($temp_content) ) {
return false;
}

$quoted_match = preg_quote($email_match, '/');
$attribute_signs = $this->getWorkingAttributeExclusionsSigns();

foreach ( $attribute_signs as $tag => $array_of_attributes ) {
if ( ! is_string($tag) || $tag === '' || ! is_array($array_of_attributes) ) {
continue;
}
Comment thread
Copilot marked this conversation as resolved.
foreach ( $array_of_attributes as $attribute ) {
//do not remove IDE highlighted unnecessary escape!
$pattern = '/<'
. $tag
. '+\s+[^>]*\b'
. $attribute
. '=((\\\')|")?[^"]*\b'
. $email_match
. '\b[^"]*((\\\')|")?"[^>]*>/';
preg_match($pattern, $temp_content, $attr_match);
if ( !empty($attr_match) ) {
if ( ! is_string($attribute) || $attribute === '' ) {
continue;
}
if ( $this->isMatchInsideAttribute($quoted_match, $attribute, $temp_content, $tag) ) {
return true;
}
}
}

foreach ( $this->attribute_exclusions_list as $attribute ) {
if ( $this->isMatchInsideAttribute($quoted_match, $attribute, $temp_content) ) {
return true;
}
}

return false;
}

/**
* @return array
*/
private function getWorkingAttributeExclusionsSigns()
{
return is_array($this->runtime_attribute_exclusions_signs)
? $this->runtime_attribute_exclusions_signs
: $this->attribute_exclusions_signs;
}

/**
* @param array $names
* @return string[]
*/
private function sanitizeAttributeNames(array $names)
{
$result = array();
foreach ( $names as $attribute ) {
if ( is_string($attribute) && $attribute !== '' ) {
$result[] = $attribute;
}
}

return $result;
}

/**
* @param string $quoted_match
* @param string $attribute
* @param string $content
* @param string|null $tag
* @return bool
*/
private function isMatchInsideAttribute($quoted_match, $attribute, $content, $tag = null)
{
$quoted_attribute = preg_quote($attribute, '/');
// Always require an HTML tag so plain text like attr="..." is not treated as markup.
$tag_prefix = $tag === null
? '<[a-zA-Z][\w:-]*\s+[^>]*'
: '<' . preg_quote($tag, '/') . '\s+[^>]*';

$pattern = '/'
. $tag_prefix
. '\b'
. $quoted_attribute
. '\s*=\s*(["\'])[^"\']*'
. $quoted_match
. '[^"\']*\1/';

return (bool) preg_match($pattern, $content);
}
}
Loading
Loading