diff --git a/ContactsEncoder.php b/ContactsEncoder.php
index beda1fa..b7b4b75 100644
--- a/ContactsEncoder.php
+++ b/ContactsEncoder.php
@@ -270,7 +270,7 @@ public function modifyContent($content, $skip_exclusions = false)
// will use this in regexp callback
$this->temp_content = $content;
- $content = self::dropAttributesContainEmail($content, self::$attributes_to_drop);
+ $content = $this->dropAttributesContainEmail($content, self::$attributes_to_drop);
// Main logic
@@ -299,14 +299,17 @@ public function modifyGlobalEmails($content)
}
$this->temp_content = $content;
+ $this->helper->indexMarkup($this->temp_content);
$match_cursor = 0;
- $replacing_result = preg_replace_callback($this->global_email_pattern, function ($matches) use (&$match_cursor) {
+ $match_offsets = $this->collectMatchOffsets($this->global_email_pattern, $content);
+ $match_offset_index = 0;
+ $replacing_result = preg_replace_callback($this->global_email_pattern, function ($matches) use ($match_offsets, &$match_offset_index, &$match_cursor) {
if ( ! isset($matches[0]) ) {
return '';
}
- $position = $this->advanceMatchCursor($matches[0], $match_cursor);
+ $position = $this->resolveNextMatchOffset($matches[0], $match_offsets, $match_offset_index, $match_cursor);
if ( $this->exclusions->isContactExcluded($matches[0]) ) {
return $matches[0];
@@ -316,26 +319,30 @@ public function modifyGlobalEmails($content)
return $matches[0];
}
- //chek if email is placed in excluded attributes and return unchanged if so
- if ( $this->helper->hasAttributeExclusions($matches[0], $this->temp_content, $position) ) {
+ // Raw text of script/style/template blocks is never markup we may rewrite.
+ if ( $this->helper->isInsideRawTextTag($matches[0], $this->temp_content, $position) ) {
return $matches[0];
}
- // skip encoding if the content in script tag
- if ( $this->helper->isInsideScriptTag($matches[0], $this->temp_content, $position) ) {
- return $matches[0];
+ // Drop the cc=/bcc= copies before the markup guards take them out of the flow.
+ if ( $this->helper->isMailtoAdditionalCopy($matches[0], $this->temp_content, $position) ) {
+ return '';
}
- if ( $this->helper->isInsideOptionTag($matches[0], $this->temp_content, $position) ) {
- return $matches[0];
+ // mailto: links are encoded in place inside the href attribute, so they bypass the markup guards.
+ if ( $this->helper->isMailto($matches[0]) ) {
+ $position = $position === false ? 0 : $position;
+
+ return $this->encodeMailtoLink($matches[0], $position);
}
- if ( $this->helper->isMailtoAdditionalCopy($matches[0], $this->temp_content, $position) ) {
- return '';
+ //chek if email is placed in excluded attributes and return unchanged if so
+ if ( $this->helper->hasAttributeExclusions($matches[0], $this->temp_content, $position) ) {
+ return $matches[0];
}
- if ( $this->helper->isMailto($matches[0]) ) {
- return $this->encodeMailtoLink($matches[0]);
+ if ( $this->helper->isInsideOptionTag($matches[0], $this->temp_content, $position) ) {
+ return $matches[0];
}
return $this->encodePlainEmail($matches[0]);
@@ -364,24 +371,35 @@ public function modifyGlobalPhoneNumbers($content)
}
$this->temp_content = $content;
+ $this->helper->indexMarkup($this->temp_content);
$phones_pattern = $this->global_phones_pattern;
$match_cursor = 0;
+ $match_offsets = $this->collectMatchOffsets($phones_pattern, $content);
+ $match_offset_index = 0;
$replacing_result = preg_replace_callback(
$phones_pattern,
- function ($matches) use (&$match_cursor) {
+ function ($matches) use ($match_offsets, &$match_offset_index, &$match_cursor) {
if ( ! isset($matches[0]) ) {
return '';
}
- $position = $this->advanceMatchCursor($matches[0], $match_cursor);
+ $position = $this->resolveNextMatchOffset($matches[0], $match_offsets, $match_offset_index, $match_cursor);
if ( $this->exclusions->isContactExcluded($matches[0]) ) {
return $matches[0];
}
+ // Raw text of script/style/template blocks is never markup we may rewrite.
+ if ( $this->helper->isInsideRawTextTag($matches[0], $this->temp_content, $position) ) {
+ return $matches[0];
+ }
+
+ // tel: links are encoded in place inside the href attribute, so they bypass the markup guards.
if ( $this->helper->isTelTag($matches[0]) ) {
- return $this->encodeTelLink($matches[0]);
+ $position = $position === false ? 0 : $position;
+
+ return $this->encodeTelLink($matches[0], $position);
}
// symbols clearance
@@ -397,11 +415,6 @@ function ($matches) use (&$match_cursor) {
return $matches[0];
}
- // check if in script
- if ( $this->helper->isInsideScriptTag($matches[0], $this->temp_content, $position) ) {
- return $matches[0];
- }
-
return $this->encodeAny(
$matches[0],
$this->global_obfuscation_mode,
@@ -421,22 +434,66 @@ function ($matches) use (&$match_cursor) {
}
/**
- * Advance the left-to-right cursor so repeated contacts use their own offset.
+ * Resolve the real offset of the current match.
+ *
+ * The offsets are collected up front with PREG_OFFSET_CAPTURE and consumed in order, because
+ * preg_match_all() and preg_replace_callback() walk the same pattern over the same subject and
+ * therefore produce the same sequence of matches. Looking the match up with strpos() instead
+ * would land on an earlier literal occurrence that the pattern did not match, e.g. the address
+ * inside `` shadowing a real one further down.
*
* @param string $match
+ * @param int[] $offsets
+ * @param int $offset_index
* @param int $cursor
* @return int|false
*/
- private function advanceMatchCursor($match, &$cursor)
+ private function resolveNextMatchOffset($match, $offsets, &$offset_index, &$cursor)
{
+ $length = strlen($match);
+
+ if ( isset($offsets[$offset_index]) ) {
+ $position = $offsets[$offset_index];
+ $offset_index++;
+
+ if ( substr($this->temp_content, $position, $length) === $match ) {
+ $cursor = $position + $length;
+
+ return $position;
+ }
+ }
+
+ // The pre-collected offsets are unusable, fall back to a left-to-right scan.
$position = strpos($this->temp_content, $match, $cursor);
if ( $position !== false ) {
- $cursor = $position + strlen($match);
+ $cursor = $position + $length;
}
return $position;
}
+ /**
+ * Collect the real offset of every match of the pattern in the content.
+ *
+ * @param string $pattern
+ * @param string $content
+ * @return int[]
+ */
+ private function collectMatchOffsets($pattern, $content)
+ {
+ $offsets = array();
+
+ if ( preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE) && isset($matches[0]) ) {
+ foreach ( $matches[0] as $match ) {
+ if ( isset($match[1]) ) {
+ $offsets[] = $match[1];
+ }
+ }
+ }
+
+ return $offsets;
+ }
+
/*
* =============== ENCODE ENTITIES ===============
*/
@@ -504,10 +561,11 @@ protected function encodeAny($string, $mode = Params::OBFUSCATION_MODE_BLUR, $re
* Method to process mailto: links.
*
* @param string $mailto_link_str
+ * @param int $position Offset of the match inside the content being processed.
*
* @return string
*/
- private function encodeMailtoLink($mailto_link_str)
+ private function encodeMailtoLink($mailto_link_str, $position = 0)
{
// Get inner tag text and place it in $matches[1]
preg_match($this->global_mailto_pattern, $mailto_link_str, $matches);
@@ -523,17 +581,18 @@ private function encodeMailtoLink($mailto_link_str)
$text = isset($mailto_inner_text) ? $mailto_inner_text : $mailto_link_str;
- return 'mailto:' . $text . '" data-original-string="' . $encoded . '" title="' . htmlspecialchars($this->getTooltip(), ENT_QUOTES, 'UTF-8');
+ return 'mailto:' . $text . $this->buildSchemeLinkAttributes($encoded, $position);
}
/**
* Method to process tel: links.
*
* @param string $tel_link_str
+ * @param int $position Offset of the match inside the content being processed.
*
* @return string
*/
- private function encodeTelLink($tel_link_str)
+ private function encodeTelLink($tel_link_str, $position = 0)
{
// Get inner tag text and place it in $matches[1]
preg_match($this->global_tel_pattern, $tel_link_str, $matches);
@@ -550,7 +609,32 @@ private function encodeTelLink($tel_link_str)
$text = isset($tel_inner_text) ? $tel_inner_text : $tel_link_str;
- return 'tel:' . $text . '" data-original-string="' . $encoded . '" title="' . htmlspecialchars($this->getTooltip(), ENT_QUOTES, 'UTF-8');
+ return 'tel:' . $text . $this->buildSchemeLinkAttributes($encoded, $position);
+ }
+
+ /**
+ * Builds the attribute tail appended to an opening tag when a scheme link is encoded in place.
+ *
+ * The tail intentionally leaves the last attribute value unclosed: the quote that used to close
+ * the original href value closes it instead. The tooltip is skipped when the tag already carries
+ * a title attribute, otherwise the markup would end up with a duplicated attribute and the
+ * author's own title would be wiped by the decoder script.
+ *
+ * @param string $encoded Encoded original string.
+ * @param int $position Offset of the match inside the content being processed.
+ *
+ * @return string
+ */
+ private function buildSchemeLinkAttributes($encoded, $position)
+ {
+ $has_own_title = $this->helper->enclosingTagHasAttribute($this->temp_content, (int)$position, 'title');
+
+ if ( $has_own_title ) {
+ return '" data-original-string="' . $encoded;
+ }
+
+ return '" title="' . htmlspecialchars($this->getTooltip(), ENT_QUOTES, 'UTF-8')
+ . '" data-original-string="' . $encoded;
}
/**
@@ -823,24 +907,75 @@ protected static function getDefaultReplacingText()
* Example: Email
* Will be turned to Email
*
+ * The attribute is dropped wherever the address sits inside its value, so
+ * title="Write to example1@mail.com" is handled as well. Matching is scoped to a
+ * single tag and a single quoted value, otherwise a greedy match would swallow the rest of the
+ * markup along with it.
+ *
* @param string $content The content to process.
* @return string The content with attributes removed.
*/
- private static function dropAttributesContainEmail($content, $tags)
+ private function dropAttributesContainEmail($content, $tags)
{
- $attribute_content_chunk = '[\s]{0,}=[\s]{0,}[\"\']\b[_A-Za-z0-9-\.]+@[_A-Za-z0-9-\.]+\..*\b[\"\']';
+ $email_pattern = '/' . self::EMAIL_PATTERN . '/';
+ $this->helper->indexMarkup($content);
+
foreach ($tags as $tag => $attribute) {
- // Regular expression to match the attribute without the tag
- $regexp_chunk_without_tag = "/{$attribute}{$attribute_content_chunk}/";
- // Regular expression to match the attribute with the tag
- $regexp_chunk_with_tag = "/<{$tag}.*{$attribute}{$attribute_content_chunk}/";
- // Find all matches of the attribute with the tag in the content
- preg_match_all($regexp_chunk_with_tag, $content, $matches);
- if (!empty($matches[0])) {
- // Remove the attribute without the tag from the content
- $content = preg_replace($regexp_chunk_without_tag, '', $content, count($matches[0]));
+ if ( ! is_string($tag) || $tag === '' || ! is_string($attribute) || $attribute === '' ) {
+ continue;
+ }
+
+ // A whole tag, with quoted attribute values consumed as a unit so `>` inside them is kept.
+ $tag_pattern = '/<' . preg_quote($tag, '/') . '\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*>/i';
+ // The target attribute inside that tag, value limited to its own quotes.
+ $attribute_pattern = '/\s' . preg_quote($attribute, '/') . '\s*=\s*(["\'])(.*?)\1/is';
+
+ $replaced = preg_replace_callback(
+ $tag_pattern,
+ function ($tag_match) use ($attribute_pattern, $email_pattern, $content) {
+ if ( ! isset($tag_match[0]) || ! is_array($tag_match[0]) || ! isset($tag_match[0][0], $tag_match[0][1]) ) {
+ return '';
+ }
+
+ $opening_tag = $tag_match[0][0];
+ $tag_offset = $tag_match[0][1];
+
+ if ( ! is_string($opening_tag) || ! is_int($tag_offset) ) {
+ return '';
+ }
+
+ if ( $this->helper->isInsideRawTextTag($opening_tag, $content, $tag_offset) ) {
+ return $opening_tag;
+ }
+
+ $stripped = preg_replace_callback(
+ $attribute_pattern,
+ static function ($attribute_match) use ($email_pattern) {
+ if ( ! isset($attribute_match[0], $attribute_match[2]) ) {
+ return '';
+ }
+
+ return preg_match($email_pattern, $attribute_match[2])
+ ? ''
+ : $attribute_match[0];
+ },
+ $opening_tag
+ );
+
+ return $stripped === null ? $opening_tag : $stripped;
+ },
+ $content,
+ -1,
+ $count,
+ PREG_OFFSET_CAPTURE
+ );
+
+ if ( $replaced !== null && $count > 0 ) {
+ $content = $replaced;
+ $this->helper->indexMarkup($content);
}
}
+
return $content;
}
diff --git a/Helper/ContactsEncoderHelper.php b/Helper/ContactsEncoderHelper.php
index 8b9a912..221c189 100644
--- a/Helper/ContactsEncoderHelper.php
+++ b/Helper/ContactsEncoderHelper.php
@@ -31,6 +31,97 @@ class ContactsEncoderHelper
*/
private $attribute_exclusions_list = array();
+ /**
+ * Tag names whose inner text is not markup and must never be rewritten.
+ * @var string[]
+ */
+ private $raw_text_tags = array('script', 'style', 'template', 'noscript');
+
+ /**
+ * Content the offset indexes below were built for.
+ * @var string|null
+ */
+ private $indexed_content;
+
+ /**
+ * Sorted list of [start, end) offsets of every raw text block in the indexed content.
+ * @var array[]
+ */
+ private $raw_text_ranges = array();
+
+ /**
+ * Checks whether the tag enclosing the given offset already declares the attribute.
+ *
+ * Scheme links are rewritten from the inside of the href value, so the encoder appends its own
+ * attributes to the opening tag. This lookup prevents emitting a duplicate of an attribute the
+ * author has already set, e.g. `title` on ``.
+ *
+ * @param string $content Whole content being processed.
+ * @param int $position Offset of the match inside $content.
+ * @param string $attribute Attribute name to look for.
+ *
+ * @return bool
+ */
+ public function enclosingTagHasAttribute($content, $position, $attribute)
+ {
+ if ( ! is_string($content) || ! is_int($position) || $position < 0 ) {
+ return false;
+ }
+
+ $tag_start = strrpos(substr($content, 0, $position), '<');
+ if ( $tag_start === false ) {
+ return false;
+ }
+
+ $opening_tag = $this->readOpeningTag($content, $tag_start);
+ if ( $opening_tag === '' ) {
+ return false;
+ }
+
+ return (bool)preg_match('/[\s\'"]' . preg_quote($attribute, '/') . '\s*=/i', $opening_tag);
+ }
+
+ /**
+ * Reads an opening tag starting at the given offset, honouring quoted attribute values so that
+ * a `>` inside a value does not terminate the tag prematurely.
+ *
+ * @param string $content
+ * @param int $tag_start Offset of the `<` character.
+ *
+ * @return string Empty string when the tag is not closed.
+ */
+ private function readOpeningTag($content, $tag_start)
+ {
+ $length = strlen($content);
+ $quote = null;
+
+ for ( $i = $tag_start + 1; $i < $length; $i++ ) {
+ $char = $content[$i];
+
+ if ( $quote !== null ) {
+ if ( $char === $quote ) {
+ $quote = null;
+ }
+ continue;
+ }
+
+ if ( $char === '"' || $char === "'" ) {
+ $quote = $char;
+ continue;
+ }
+
+ if ( $char === '>' ) {
+ return substr($content, $tag_start, $i - $tag_start + 1);
+ }
+
+ if ( $char === '<' ) {
+ return '';
+ }
+ }
+
+ return '';
+ }
+
/**
* Checking if the string contains mailto: link
*
@@ -125,28 +216,134 @@ public function isInsideOptionTag($email, $content, $position = null)
* @param string $content The full content
* @param int|false|null $position Known match offset; null looks up the first occurrence
* @return bool
+ * @psalm-suppress PossiblyUnusedMethod
*/
public function isInsideScriptTag($email, $content, $position = null)
{
- $pos = $this->resolveMatchPosition($email, $content, $position);
- if ($pos === false) {
+ return $this->isInsideRawTextTag($email, $content, $position, array('script'));
+ }
+
+ /**
+ * Check whether the match sits inside the raw text of a tag whose content is not markup
+ * (script, style, template, noscript). Covers inline scripts, JSON-LD and CSS at once.
+ *
+ * @param string $needle The matched contact
+ * @param string $content The full content
+ * @param int|false|null $position Known match offset; null looks up the first occurrence
+ * @param string[]|null $tags Restrict the check to these tags; null uses the full raw text list
+ * @return bool
+ */
+ public function isInsideRawTextTag($needle, $content, $position = null, $tags = null)
+ {
+ $pos = $this->resolveMatchPosition($needle, $content, $position);
+ if ( $pos === false ) {
return false;
}
- // Find the last script opening tag before the email
- $last_script_start = strrpos(substr($content, 0, $pos), '', $last_script_start);
- if ($script_end === false) {
+ $range_tag = $this->raw_text_ranges[$index][2];
+
+ return $tags === null || in_array($range_tag, $tags, true);
+ }
+
+ /**
+ * Build the raw text offset index for the given content once.
+ * Repeated calls with the same content reuse the cached index.
+ *
+ * @param string $content
+ * @return void
+ */
+ public function indexMarkup($content)
+ {
+ if ( ! is_string($content) ) {
+ return;
+ }
+
+ if ( $this->indexed_content !== null && $this->indexed_content === $content ) {
+ return;
+ }
+
+ $this->indexed_content = $content;
+ $this->raw_text_ranges = $this->buildRawTextRanges($content);
+ }
+
+ /**
+ * Offsets of the inner text of every raw text tag.
+ *
+ * @param string $content
+ * @return array[] list of [start, end, tag]
+ */
+ private function buildRawTextRanges($content)
+ {
+ $ranges = array();
+ if ( empty($this->raw_text_tags) ) {
+ return $ranges;
+ }
+
+ $tags = array();
+ foreach ( $this->raw_text_tags as $tag ) {
+ if ( is_string($tag) && $tag !== '' ) {
+ $tags[] = preg_quote($tag, '/');
+ }
+ }
+
+ if ( empty($tags) ) {
+ return $ranges;
+ }
+
+ $pattern = '/<(' . implode('|', $tags) . ')\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*>(.*?)<\/\1\s*>/is';
+
+ if ( preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE) && isset($matches[2]) ) {
+ foreach ( $matches[2] as $index => $match ) {
+ if ( ! isset($match[0], $match[1]) || $match[1] < 0 ) {
+ continue;
+ }
+ $tag = isset($matches[1][$index][0]) ? strtolower($matches[1][$index][0]) : '';
+ $ranges[] = array($match[1], $match[1] + strlen($match[0]), $tag);
+ }
+ }
+
+ return $ranges;
+ }
+
+ /**
+ * Index of the range containing the offset, or false when the offset is outside all of them.
+ *
+ * @param array[] $ranges
+ * @param int $position
+ * @return int|false
+ */
+ private function findRangeIndex($ranges, $position)
+ {
+ if ( ! is_array($ranges) || empty($ranges) ) {
return false;
}
- // The email is inside a script tag if its position is between the opening and closing tags
- return ($pos > $last_script_start && $pos < $script_end);
+ $low = 0;
+ $high = count($ranges) - 1;
+
+ while ( $low <= $high ) {
+ $middle = intdiv($low + $high, 2);
+ if ( ! isset($ranges[$middle][0], $ranges[$middle][1]) ) {
+ return false;
+ }
+
+ if ( $position < $ranges[$middle][0] ) {
+ $high = $middle - 1;
+ } elseif ( $position >= $ranges[$middle][1] ) {
+ $low = $middle + 1;
+ } else {
+ return $middle;
+ }
+ }
+
+ return false;
}
/**
diff --git a/tests/.phpcs.xml b/tests/.phpcs.xml
index 78a198a..84bb718 100644
--- a/tests/.phpcs.xml
+++ b/tests/.phpcs.xml
@@ -23,7 +23,7 @@
-
+
diff --git a/tests/psalm.xml b/tests/psalm.xml
index 9729162..98bdf6e 100644
--- a/tests/psalm.xml
+++ b/tests/psalm.xml
@@ -11,7 +11,7 @@
ensureArrayIntOffsetsExist="true"
allowNamedArgumentCalls="false"
hoistConstants="true"
- phpVersion="5.6"
+ phpVersion="7.4"
hideExternalErrors="true"
xsi:schemaLocation="https://getpsalm.org/schema/config config.xsd"
limitMethodComplexity="true"