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
215 changes: 175 additions & 40 deletions ContactsEncoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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];
Expand All @@ -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];
Comment thread
alexandergull marked this conversation as resolved.
}

// 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]);
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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 `<script>var t="user@example.com1";</script>` 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 ===============
*/
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -823,24 +907,75 @@ protected static function getDefaultReplacingText()
* Example: <code><a title="example1@mail.com" href="mailto:example2@mail.com">Email</a></code>
* Will be turned to <code><a href="mailto:example2@mail.com">Email</a></code>
*
* The attribute is dropped wherever the address sits inside its value, so
* <code>title="Write to example1@mail.com"</code> 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;
}

Expand Down
Loading
Loading