diff --git a/ContactsEncoder.php b/ContactsEncoder.php index 808f25b..9914614 100644 --- a/ContactsEncoder.php +++ b/ContactsEncoder.php @@ -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. @@ -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; @@ -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; } @@ -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)); + } } diff --git a/Helper/ContactsEncoderHelper.php b/Helper/ContactsEncoderHelper.php index f3cc28b..0c61001 100644 --- a/Helper/ContactsEncoderHelper.php +++ b/Helper/ContactsEncoderHelper.php @@ -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 * @@ -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); + } } diff --git a/tests/ContactsEncoder/TestContactsEncoderAriaLabel.php b/tests/ContactsEncoder/TestContactsEncoderAriaLabel.php new file mode 100644 index 0000000..89008ad --- /dev/null +++ b/tests/ContactsEncoder/TestContactsEncoderAriaLabel.php @@ -0,0 +1,73 @@ +api_key = 'test_api_key'; + $params->obfuscation_mode = Params::OBFUSCATION_MODE_BLUR; + $params->obfuscation_text = ''; + $params->do_encode_emails = true; + $params->do_encode_phones = false; + $params->is_logged_in = false; + + return $concrete::getInstance($params); + } + + public function testModifyContentPreservesAriaLabelWithEmail() + { + $email = 'info@example.com'; + $content = ''; + + $result = $this->createEncoder()->modifyContent($content); + + $this->assertStringContainsString('aria-label="Contact us at ' . $email . '"', $result); + $this->assertStringNotContainsString('%%APBCT_ARIA_', $result); + $this->assertStringNotContainsString('ct_temp_aria_', $result); + } + + public function testModifyContentDoesNotRestorePlantedCtTempAriaToken() + { + $payload = '
test' + . 'ct_temp_aria_0' + . 'test'; + + $result = $this->createEncoder()->modifyContent($payload); + + $this->assertStringContainsString('ct_temp_aria_0', $result); + $this->assertNotRegExp('/>\s*aria-label\s*=/', $result); + } + + public function testModifyContentWordfenceAriaLabelXssPayloadDoesNotBreakOut() + { + $payload = '
test' . "\n" + . 'ct_temp_aria_0' + . 'test'; + + $result = $this->createEncoder()->modifyContent($payload); + + $this->assertTrue(strpos($result, 'ct_temp_aria_0') !== false); + $this->assertFalse((bool) preg_match('/>\s*aria-label\s*=/', $result)); + } +} diff --git a/tests/ContactsEncoder/TestContactsEncoderAttributeExclusions.php b/tests/ContactsEncoder/TestContactsEncoderAttributeExclusions.php new file mode 100644 index 0000000..b050bb2 --- /dev/null +++ b/tests/ContactsEncoder/TestContactsEncoderAttributeExclusions.php @@ -0,0 +1,128 @@ +api_key = 'test_api_key'; + $params->obfuscation_mode = Params::OBFUSCATION_MODE_BLUR; + $params->obfuscation_text = ''; + $params->do_encode_emails = true; + $params->do_encode_phones = $do_encode_phones; + $params->is_logged_in = false; + + $encoder = $concrete::getInstance($params); + $encoder->dropInstance(); + + return $concrete::getInstance($params); + } + + public function testHasAttributeExclusionsForInputDataMask() + { + $helper = new ContactsEncoderHelper(); + $mask = '(999) 999-9999'; + $content = ''; + + $this->assertTrue($helper->hasAttributeExclusions($mask, $content)); + } + + public function testHasAttributeExclusionsForInputPlaceholder() + { + $helper = new ContactsEncoderHelper(); + $email = 'info@example.com'; + $content = ''; + + $this->assertTrue($helper->hasAttributeExclusions($email, $content)); + } + + public function testHasAttributeExclusionsReturnsFalseForPlainPhone() + { + $helper = new ContactsEncoderHelper(); + $phone = '(800) 555-1234'; + + $this->assertFalse($helper->hasAttributeExclusions($phone, 'Call us at ' . $phone)); + } + + public function testModifyContentDoesNotEncodeGravityFormsPhoneMask() + { + $mask = '(999) 999-9999'; + $visible_phone = '(800) 555-1234'; + $content = 'Call ' . $visible_phone + . ' '; + + $result = $this->createEncoder(true)->modifyContent($content); + + $this->assertStringContainsString('data-mask="' . $mask . '"', $result); + $this->assertStringNotContainsString($visible_phone, $result); + $this->assertStringContainsString('apbct-email-encoder', $result); + } + + public function testModifyContentDoesNotEncodePlaceholderPhoneMask() + { + $mask = '(999) 999-9999'; + $content = ''; + + $result = $this->createEncoder(true)->modifyContent($content); + + $this->assertStringContainsString('placeholder="' . $mask . '"', $result); + } + + public function testHasAttributeExclusionsHonorsAddAttributeNames() + { + $helper = new ContactsEncoderHelper(); + $helper->addAttributeNames(array('data-phone-format')); + + $mask = '(999) 321-1233'; + $content = ''; + + $this->assertTrue($helper->hasAttributeExclusions($mask, $content)); + } + + public function testHasAttributeExclusionsHonorsAddAttributeExclusions() + { + $helper = new ContactsEncoderHelper(); + $helper->addAttributeExclusions('span', array('data-phone-mask')); + + $mask = '(999) 321-1233'; + $content = ''; + + $this->assertTrue($helper->hasAttributeExclusions($mask, $content)); + } + + public function testHasAttributeExclusionsIgnoresPlainTextAttributeAssignment() + { + $helper = new ContactsEncoderHelper(); + $helper->addAttributeNames(array('data-phone-format')); + + $mask = '(999) 321-1233'; + $content = 'Set data-phone-format="' . $mask . '" in the docs'; + + $this->assertFalse($helper->hasAttributeExclusions($mask, $content)); + } + + protected function tearDown(): void + { + $this->createEncoder(false)->dropInstance(); + } +}