diff --git a/src/markup/__tests__/PhutilMarkupTestCase.php b/src/markup/__tests__/PhutilMarkupTestCase.php index d333f4f..0197ab6 100644 --- a/src/markup/__tests__/PhutilMarkupTestCase.php +++ b/src/markup/__tests__/PhutilMarkupTestCase.php @@ -1,264 +1,265 @@ assertEqual( (string)phutil_tag('br'), (string)phutil_tag('br', array())); $this->assertEqual( (string)phutil_tag('br', array()), (string)phutil_tag('br', array(), null)); } public function testTagEmpty() { $this->assertEqual( '
', (string)phutil_tag('br', array(), null)); $this->assertEqual( '
', (string)phutil_tag('div', array(), null)); $this->assertEqual( '
', (string)phutil_tag('div', array(), '')); } public function testTagBasics() { $this->assertEqual( '
', (string)phutil_tag('br')); $this->assertEqual( '
y
', (string)phutil_tag('div', array(), 'y')); } public function testTagAttributes() { $this->assertEqual( '
y
', (string)phutil_tag('div', array('u' => 'v'), 'y')); $this->assertEqual( '
', (string)phutil_tag('br', array('u' => 'v'))); } public function testTagEscapes() { $this->assertEqual( '
', (string)phutil_tag('br', array('u' => '<'))); $this->assertEqual( '

', (string)phutil_tag('div', array(), phutil_tag('br'))); } public function testTagNullAttribute() { $this->assertEqual( '
', (string)phutil_tag('br', array('y' => null))); } public function testDefaultRelNoreferrer() { $map = array( // These should not have rel="nofollow" inserted implicitly. '/' => false, '/path/to/local.html' => false, '#example' => false, '' => false, // These should get the implicit insertion. 'http://www.example.org/' => true, '///evil.com/' => true, ' http://www.example.org/' => true, 'ftp://filez.com' => true, 'mailto:santa@northpole.com' => true, + 'tel:18005555555' => true, ); foreach ($map as $input => $expect) { $tag = phutil_tag( 'a', array( 'href' => $input, ), 'link'); $tag = (string)$tag; $this->assertEqual($expect, (bool)preg_match('/noreferrer/', $tag)); } // With an explicit `rel` present, we should not override it. $tag = phutil_tag( 'a', array( 'href' => 'http://www.example.org/', 'rel' => 'nofollow', ), 'link'); $this->assertFalse((bool)preg_match('/noreferrer/', (string)$tag)); // For tags other than `a`, we should not insert `rel`. $tag = phutil_tag( 'link', array( 'href' => 'http://www.example.org/', ), 'link'); $this->assertFalse((bool)preg_match('/noreferrer/', (string)$tag)); } public function testTagJavascriptProtocolRejection() { $hrefs = array( 'javascript:alert(1)' => true, 'JAVASCRIPT:alert(2)' => true, // NOTE: When interpreted as a URI, this is dropped because of leading // whitespace. ' javascript:alert(3)' => array(true, false), '/' => false, '/path/to/stuff/' => false, '' => false, 'http://example.com/' => false, '#' => false, 'javascript://anything' => true, // Chrome 33 and IE11, at a minimum, treat this as Javascript. "javascript\n:alert(4)" => true, // Opera currently accepts a variety of unicode spaces. This test case // has a smattering of them. "\xE2\x80\x89javascript:" => true, "javascript\xE2\x80\x89:" => true, "\xE2\x80\x84javascript:" => true, "javascript\xE2\x80\x84:" => true, // Because we're aggressive, all of unicode should trigger detection // by default. "\xE2\x98\x83javascript:" => true, "javascript\xE2\x98\x83:" => true, "\xE2\x98\x83javascript\xE2\x98\x83:" => true, // We're aggressive about this, so we'll intentionally raise false // positives in these cases. 'javascript~:alert(5)' => true, '!!!javascript!!!!:alert(6)' => true, // However, we should raise true negatives in these slightly more // reasonable cases. 'javascript/:docs.html' => false, 'javascripts:x.png' => false, 'COOLjavascript:page' => false, '/javascript:alert(1)' => false, ); foreach (array(true, false) as $use_uri) { foreach ($hrefs as $href => $expect) { if (is_array($expect)) { $expect = ($use_uri ? $expect[1] : $expect[0]); } if ($use_uri) { $href = new PhutilURI($href); } $caught = null; try { phutil_tag('a', array('href' => $href), 'click for candy'); } catch (Exception $ex) { $caught = $ex; } $this->assertEqual( $expect, $caught instanceof Exception, "Rejected href: {$href}"); } } } public function testURIEscape() { $this->assertEqual( '%2B/%20%3F%23%26%3A%21xyz%25', phutil_escape_uri('+/ ?#&:!xyz%')); } public function testURIPathComponentEscape() { $this->assertEqual( 'a%252Fb', phutil_escape_uri_path_component('a/b')); $str = ''; for ($ii = 0; $ii <= 255; $ii++) { $str .= chr($ii); } $this->assertEqual( $str, phutil_unescape_uri_path_component( rawurldecode( // Simulates webserver. phutil_escape_uri_path_component($str)))); } public function testHsprintf() { $this->assertEqual( '
<3
', (string)hsprintf('
%s
', '<3')); } public function testAppendHTML() { $html = phutil_tag('hr'); $html->appendHTML(phutil_tag('br'), ''); $this->assertEqual('

<evil>', $html->getHTMLContent()); } public function testArrayEscaping() { $this->assertEqual( '
<div>
', phutil_escape_html( array( hsprintf('
'), array( array( '<', array( 'd', array( array( hsprintf('i'), ), 'v', ), ), array( array( '>', ), ), ), ), hsprintf('
'), ))); $this->assertEqual( '


', phutil_tag( 'div', array(), array( array( array( phutil_tag('br'), array( phutil_tag('hr'), ), phutil_tag('wbr'), ), ), ))->getHTMLContent()); } } diff --git a/src/markup/engine/__tests__/PhutilRemarkupEngineTestCase.php b/src/markup/engine/__tests__/PhutilRemarkupEngineTestCase.php index 0547a5c..692f932 100644 --- a/src/markup/engine/__tests__/PhutilRemarkupEngineTestCase.php +++ b/src/markup/engine/__tests__/PhutilRemarkupEngineTestCase.php @@ -1,113 +1,114 @@ markupText($root.$file); } } private function markupText($markup_file) { $contents = Filesystem::readFile($markup_file); $file = basename($markup_file); $parts = explode("\n~~~~~~~~~~\n", $contents); $this->assertEqual(3, count($parts), $markup_file); list($input_remarkup, $expected_output, $expected_text) = $parts; $engine = $this->buildNewTestEngine(); switch ($file) { case 'raw-escape.txt': // NOTE: Here, we want to test PhutilRemarkupEscapeRemarkupRule and // PhutilRemarkupBlockStorage, which are triggered by "\1". In the // test, "~" is used as a placeholder for "\1" since it's hard to type // "\1". $input_remarkup = str_replace('~', "\1", $input_remarkup); $expected_output = str_replace('~', "\1", $expected_output); $expected_text = str_replace('~', "\1", $expected_text); break; case 'toc.txt': $engine->setConfig('header.generate-toc', true); break; } $actual_output = (string)$engine->markupText($input_remarkup); switch ($file) { case 'toc.txt': $table_of_contents = PhutilRemarkupHeaderBlockRule::renderTableOfContents($engine); $actual_output = $table_of_contents."\n\n".$actual_output; break; } $this->assertEqual( $expected_output, $actual_output, "Failed to markup HTML in file '{$file}'."); $engine->setMode(PhutilRemarkupEngine::MODE_TEXT); $actual_output = (string)$engine->markupText($input_remarkup); $this->assertEqual( $expected_text, $actual_output, "Failed to markup text in file '{$file}'."); } private function buildNewTestEngine() { $engine = new PhutilRemarkupEngine(); $engine->setConfig('uri.prefix', 'http://www.example.com/'); $engine->setConfig( 'uri.allowed-protocols', array( 'http' => true, 'mailto' => true, + 'tel' => true, )); $rules = array(); $rules[] = new PhutilRemarkupEscapeRemarkupRule(); $rules[] = new PhutilRemarkupMonospaceRule(); $rules[] = new PhutilRemarkupDocumentLinkRule(); $rules[] = new PhutilRemarkupHyperlinkRule(); $rules[] = new PhutilRemarkupBoldRule(); $rules[] = new PhutilRemarkupItalicRule(); $rules[] = new PhutilRemarkupDelRule(); $rules[] = new PhutilRemarkupUnderlineRule(); $blocks = array(); $blocks[] = new PhutilRemarkupQuotesBlockRule(); $blocks[] = new PhutilRemarkupReplyBlockRule(); $blocks[] = new PhutilRemarkupHeaderBlockRule(); $blocks[] = new PhutilRemarkupHorizontalRuleBlockRule(); $blocks[] = new PhutilRemarkupCodeBlockRule(); $blocks[] = new PhutilRemarkupLiteralBlockRule(); $blocks[] = new PhutilRemarkupNoteBlockRule(); $blocks[] = new PhutilRemarkupTableBlockRule(); $blocks[] = new PhutilRemarkupSimpleTableBlockRule(); $blocks[] = new PhutilRemarkupDefaultBlockRule(); $blocks[] = new PhutilRemarkupListBlockRule(); $blocks[] = new PhutilRemarkupInterpreterBlockRule(); foreach ($blocks as $block) { if (!($block instanceof PhutilRemarkupCodeBlockRule)) { $block->setMarkupRules($rules); } } $engine->setBlockRules($blocks); return $engine; } } diff --git a/src/markup/engine/__tests__/remarkup/link-tel.txt b/src/markup/engine/__tests__/remarkup/link-tel.txt new file mode 100644 index 0000000..aac13c2 --- /dev/null +++ b/src/markup/engine/__tests__/remarkup/link-tel.txt @@ -0,0 +1,18 @@ +[[ tel:18005555555 | call me ]] + +[ call me ]( tel:18005555555 ) + +[[tel:18005555555]] + +~~~~~~~~~~ +

call me

+ +

call me

+ +

18005555555

+~~~~~~~~~~ +call me <18005555555> + +call me <18005555555> + +18005555555 diff --git a/src/markup/engine/remarkup/markuprule/PhutilRemarkupDocumentLinkRule.php b/src/markup/engine/remarkup/markuprule/PhutilRemarkupDocumentLinkRule.php index 0d5debf..bbd23be 100644 --- a/src/markup/engine/remarkup/markuprule/PhutilRemarkupDocumentLinkRule.php +++ b/src/markup/engine/remarkup/markuprule/PhutilRemarkupDocumentLinkRule.php @@ -1,123 +1,124 @@ getEngine()->isTextMode()) { $text = $link; if (strncmp($link, '/', 1) == 0 || strncmp($link, '#', 1) == 0) { $base = $this->getEngine()->getConfig('uri.prefix'); if (strncmp($link, '/', 1) == 0) { $base = rtrim($base, '/'); } $text = $base.$text; } - // If present, strip off "mailto:". - $text = preg_replace('/^mailto:/', '', $text); + // If present, strip off "mailto:" or "tel:". + $text = preg_replace('/^(?:mailto|tel):/', '', $text); if ($link == $name) { return $text; } return $name.' <'.$text.'>'; } // By default, we open links in a new window or tab. For anchors on the same // page, just jump normally. $target = '_blank'; if (strncmp($link, '#', 1) == 0) { $target = null; } - $name = preg_replace('/^mailto:/', '', $name); + $name = preg_replace('/^(?:mailto|tel):/', '', $name); if ($this->getEngine()->getState('toc')) { return $name; } else { return phutil_tag( 'a', array( 'href' => $link, 'class' => 'remarkup-link', 'target' => $target, ), $name); } } public function markupAlternateLink($matches) { $uri = trim($matches[2]); // NOTE: We apply some special rules to avoid false positives here. The // major concern is that we do not want to convert `x[0][1](y)` in a // discussion about C source code into a link. To this end, we: // // - Don't match at word boundaries; // - require the URI to contain a "/" character or "@" character; and // - reject URIs which being with a quote character. if ($uri[0] == '"' || $uri[0] == "'" || $uri[0] == '`') { return $matches[0]; } if (strpos($uri, '/') === false && - strpos($uri, '@') === false) { + strpos($uri, '@') === false && + strncmp($uri, 'tel:', 4)) { return $matches[0]; } return $this->markupDocumentLink( array( $matches[0], $matches[2], $matches[1], )); } public function markupDocumentLink($matches) { $uri = trim($matches[1]); $name = trim(idx($matches, 2, $uri)); // If whatever is being linked to begins with "/" or "#", or has "://", - // or is "mailto:", treat it as a URI instead of a wiki page. - $is_uri = preg_match('@(^/)|(://)|(^#)|(^mailto:)@', $uri); + // or is "mailto:" or "tel:", treat it as a URI instead of a wiki page. + $is_uri = preg_match('@(^/)|(://)|(^#)|(^(?:mailto|tel):)@', $uri); if ($is_uri && strncmp('/', $uri, 1) && strncmp('#', $uri, 1)) { $protocols = $this->getEngine()->getConfig( 'uri.allowed-protocols', array()); $protocol = id(new PhutilURI($uri))->getProtocol(); if (!idx($protocols, $protocol)) { // Don't treat this as a URI if it's not an allowed protocol. $is_uri = false; } } if (!$is_uri) { return $matches[0]; } return $this->getEngine()->storeText($this->renderHyperlink($uri, $name)); } }