Skip to content
Open
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
101 changes: 84 additions & 17 deletions 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);
}
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,84 @@ 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 ( function_exists('random_bytes') ) {
try {
// phpcs:ignore PHPCompatibility.FunctionUse.NewFunctions.random_bytesFound
$bytes = random_bytes($length);
if ( is_string($bytes) && strlen($bytes) === $length ) {
return $bytes;
}
} catch ( \Exception $e ) {
// Fall through to OpenSSL.
}
}

if ( function_exists('openssl_random_pseudo_bytes') ) {
$bytes = openssl_random_pseudo_bytes($length);
if ( is_string($bytes) && strlen($bytes) === $length ) {
return $bytes;
}
}

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,12 +12,24 @@ class ContactsEncoderHelper
* @var array[]
*/
private $attribute_exclusions_signs = array(
'input' => array('placeholder', 'value'),
'input' => array('placeholder', 'value', 'data-mask'),
'sc-customer-email' => array('placeholder', 'value'),
'img' => array('alt', 'title'),
'div' => array('data-et-multi-view'),
);

/**
* 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 @@ -127,31 +139,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_array($array_of_attributes) ) {
continue;
}
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