diff --git a/src/markup/__tests__/PhutilMarkupTestCase.php b/src/markup/__tests__/PhutilMarkupTestCase.php index 81301cd..33df7d4 100644 --- a/src/markup/__tests__/PhutilMarkupTestCase.php +++ b/src/markup/__tests__/PhutilMarkupTestCase.php @@ -1,179 +1,182 @@ assertEqual( - (string)phutil_tag('x'), - (string)phutil_tag('x', array())); + (string)phutil_tag('br'), + (string)phutil_tag('br', array())); $this->assertEqual( - (string)phutil_tag('x', array()), - (string)phutil_tag('x', array(), null)); + (string)phutil_tag('br', array()), + (string)phutil_tag('br', array(), null)); } public function testTagEmpty() { $this->assertEqual( - '', - (string)phutil_tag('x', array(), null)); + '
', + (string)phutil_tag('br', array(), null)); $this->assertEqual( - '', - (string)phutil_tag('x', array(), '')); - } + '
', + (string)phutil_tag('div', array(), null)); + $this->assertEqual( + '
', + (string)phutil_tag('div', array(), '')); + } public function testTagBasics() { $this->assertEqual( - '', - (string)phutil_tag('x')); + '
', + (string)phutil_tag('br')); $this->assertEqual( - 'y', - (string)phutil_tag('x', array(), 'y')); + '
y
', + (string)phutil_tag('div', array(), 'y')); } public function testTagAttributes() { $this->assertEqual( - 'y', - (string)phutil_tag('x', array('u' => 'v'), 'y')); + '
y
', + (string)phutil_tag('div', array('u' => 'v'), 'y')); $this->assertEqual( - '', - (string)phutil_tag('x', array('u' => 'v'))); + '
', + (string)phutil_tag('br', array('u' => 'v'))); } public function testTagEscapes() { $this->assertEqual( - '', - (string)phutil_tag('x', array('u' => '<'))); + '
', + (string)phutil_tag('br', array('u' => '<'))); $this->assertEqual( - '', - (string)phutil_tag('x', array(), phutil_tag('y'))); + '

', + (string)phutil_tag('div', array(), phutil_tag('br'))); } public function testTagNullAttribute() { $this->assertEqual( - '', - (string)phutil_tag('x', array('y' => null))); + '
', + (string)phutil_tag('br', array('y' => null))); } public function testTagJavascriptProtocolRejection() { $hrefs = array( 'javascript:alert(1)' => true, 'JAVASCRIPT:alert(1)' => true, ' javascript:alert(1)' => true, '/' => false, '/path/to/stuff/' => false, '' => false, 'http://example.com/' => false, '#' => false, ); foreach (array(true, false) as $use_uri) { foreach ($hrefs as $href => $expect) { 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('span'); - $html->appendHTML(phutil_tag('em'), ''); - $this->assertEqual('<evil>', $html->getHTMLContent()); + $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('x'), + phutil_tag('br'), array( - phutil_tag('y'), + phutil_tag('hr'), ), - phutil_tag('z'), + phutil_tag('wbr'), ), ), ))->getHTMLContent()); } } diff --git a/src/markup/render.php b/src/markup/render.php index 82c4a19..bf0ba24 100644 --- a/src/markup/render.php +++ b/src/markup/render.php @@ -1,209 +1,234 @@ getHTMLContent(); } /** * @group markup */ function phutil_tag($tag, array $attributes = array(), $content = null) { if (!empty($attributes['href'])) { // This might be a URI object, so cast it to a string. $href = (string)$attributes['href']; // Block 'javascript:' hrefs at the tag level: no well-designed application // should ever use them, and they are a potent attack vector. This function // is deep in the core and performance sensitive, so skip the relatively // expensive preg_match() call if the initial character is '/' (this is the // case with essentially every URI Phabricator renders). if (isset($href[0]) && ($href[0] != '/') && preg_match('/^\s*javascript:/i', $href)) { throw new Exception( "Attempting to render a tag with an 'href' attribute that begins ". "with 'javascript:'. This is either a serious security concern or a ". "serious architecture concern. Seek urgent remedy."); } } + // For tags which can't self-close, treat null as the empty string -- for + // example, always render `
`, never `
`. + static $self_closing_tags = array( + 'area' => true, + 'base' => true, + 'br' => true, + 'col' => true, + 'command' => true, + 'embed' => true, + 'hr' => true, + 'img' => true, + 'input' => true, + 'keygen' => true, + 'link' => true, + 'meta' => true, + 'param' => true, + 'source' => true, + 'track' => true, + 'wbr' => true, + ); + + if ($content === null && empty($self_closing_tags[$tag])) { + $content = ''; + } + foreach ($attributes as $k => $v) { if ($v === null) { continue; } $v = phutil_escape_html($v); $attributes[$k] = ' '.$k.'="'.$v.'"'; } $attributes = implode('', $attributes); if ($content === null) { return new PhutilSafeHTML('<'.$tag.$attributes.' />'); } $content = phutil_escape_html($content); return new PhutilSafeHTML('<'.$tag.$attributes.'>'.$content.''); } /** * @group markup */ function phutil_escape_html($string) { if ($string instanceof PhutilSafeHTML) { return $string; } else if ($string instanceof PhutilSafeHTMLProducerInterface) { $result = $string->producePhutilSafeHTML(); if ($result instanceof PhutilSafeHTML) { return phutil_escape_html($result); } else if (is_array($result)) { return phutil_escape_html($result); } else if ($result instanceof PhutilSafeHTMLProducerInterface) { return phutil_escape_html($result); } else { try { assert_stringlike($result); return phutil_escape_html((string)$result); } catch (Exception $ex) { $class = get_class($string); throw new Exception( "Object (of class '{$class}') implements ". "PhutilSafeHTMLProducerInterface but did not return anything ". "renderable from producePhutilSafeHTML()."); } } } else if (is_array($string)) { return implode('', array_map('phutil_escape_html', $string)); } return htmlspecialchars($string, ENT_QUOTES, 'UTF-8'); } /** * @group markup */ function phutil_escape_html_newlines($string) { return PhutilSafeHTML::applyFunction('nl2br', $string); } /** * Mark string as safe for use in HTML. * * @group markup */ function phutil_safe_html($string) { if ($string == '') { return $string; } else if ($string instanceof PhutilSafeHTML) { return $string; } else { return new PhutilSafeHTML($string); } } /** * HTML safe version of implode(). * * @group markup */ function phutil_implode_html($glue, array $pieces) { $glue = phutil_escape_html($glue); $pieces = array_map('phutil_escape_html', $pieces); return phutil_safe_html(implode($glue, $pieces)); } /** * Format a HTML code. This function behaves like sprintf(), except that all * the normal conversions (like %s) will be properly escaped. * * @group markup */ function hsprintf($html/* , ... */) { $args = func_get_args(); array_shift($args); return new PhutilSafeHTML( vsprintf($html, array_map('phutil_escape_html', $args))); } /** * Escape text for inclusion in a URI or a query parameter. Note that this * method does NOT escape '/', because "%2F" is invalid in paths and Apache * will automatically 404 the page if it's present. This will produce correct * (the URIs will work) and desirable (the URIs will be readable) behavior in * these cases: * * '/path/?param='.phutil_escape_uri($string); # OK: Query Parameter * '/path/to/'.phutil_escape_uri($string); # OK: URI Suffix * * It will potentially produce the WRONG behavior in this special case: * * COUNTEREXAMPLE * '/path/to/'.phutil_escape_uri($string).'/thing/'; # BAD: URI Infix * * In this case, any '/' characters in the string will not be escaped, so you * will not be able to distinguish between the string and the suffix (unless * you have more information, like you know the format of the suffix). For infix * URI components, use @{function:phutil_escape_uri_path_component} instead. * * @param string Some string. * @return string URI encoded string, except for '/'. * * @group markup */ function phutil_escape_uri($string) { return str_replace('%2F', '/', rawurlencode($string)); } /** * Escape text for inclusion as an infix URI substring. See discussion at * @{function:phutil_escape_uri}. This function covers an unusual special case; * @{function:phutil_escape_uri} is usually the correct function to use. * * This function will escape a string into a format which is safe to put into * a URI path and which does not contain '/' so it can be correctly parsed when * embedded as a URI infix component. * * However, you MUST decode the string with * @{function:phutil_unescape_uri_path_component} before it can be used in the * application. * * @param string Some string. * @return string URI encoded string that is safe for infix composition. * * @group markup */ function phutil_escape_uri_path_component($string) { return rawurlencode(rawurlencode($string)); } /** * Unescape text that was escaped by * @{function:phutil_escape_uri_path_component}. See * @{function:phutil_escape_uri} for discussion. * * Note that this function is NOT the inverse of * @{function:phutil_escape_uri_path_component}! It undoes additional escaping * which is added to survive the implied unescaping performed by the webserver * when interpreting the request. * * @param string Some string emitted * from @{function:phutil_escape_uri_path_component} and * then accessed via a web server. * @return string Original string. * @group markup */ function phutil_unescape_uri_path_component($string) { return rawurldecode($string); }