Security: stored XSS by members through editor HTML, user attributes and page names
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 5/5
- Thời gian dự kiến
- Hơn một tuần
- Mức phù hợp với người mới
- 35/100
Hướng nghiên cứu
Start with the affected PHP files listed in the issue: system/core/document.php, system/core/pages.php, system/core/base.php, and the two files under system/datatypes/. Review the suggested patch and check the HTML sanitization, output escaping, username validation, and page creation against the described attack paths. Done means all listed paths are addressed while existing editor content continues to work; the payload names no tests.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Summary
Editor fields are only checked for <script> elements. Event handler attributes, javascript: URLs and similar constructs pass the check and are stored. In addition, several views print the user name, full name and e-mail address of members, and page names, into HTML without escaping. Any member can thereby store markup that runs script in the browser of other members and administrators, and a link from the editor can create a page whose name carries markup.
Severity: High. Any registered member (also an invitee who just registered) can run script in the session of an administrator, who can then be made to change site settings, users or themes.
Affected code (dev, 5dae13a)
system/core/document.php:735-753HTMLForm::validateHtmlField()only rejectsscriptelements; every<editor>field of every datatype (text page, mailing list, peer-reviewed article) relies on it.system/core/pages.php:577wikify_link()creates a new page for any link of the form<language>/<name>in editor HTML, withoutvalidatePagename(), so the name can contain markup.system/datatypes/index.php:70prints page names (and the data type) unescaped in the page index.system/core/base.php:815hypha_indexLanguages()prints the page name unescaped in a link.system/datatypes/settings.php:59, 66, 68, 78, 188, 190, 200, 631, 635, 637, 641, 643, 646, 678, 679, 681print user name, full name, e-mail address (and the user name from the URL) unescaped;:646puts the user name in a JavaScript string.system/core/base.php:470-488hypha_setUser()accepts any character in a user name, while the name is used in URLs and in JavaScript string arguments built bymakeAction()(settings.php:59, 686, 694).
The same code is present on master (other line numbers).
Impact
A member can store content that executes script when another member or an administrator views a page, the page index, the language selector or the member list in the settings. The script runs with the rights of the viewer, so an administrator's session can be used to perform administrative actions.
Suggested fix
- Sanitise editor HTML on the server instead of only looking for
script: remove elements that execute or redirect (script,style,base,meta,link,frame(set),applet,template,noscript,svg,math, ...), allon*attributes andsrcdoc, styles with script constructs, and every URL attribute whose scheme is not on an allow-list (relative,http,https,mailto,tel, andhypha:page links as stored bywikify(); rasterdata:imageonly forimg src). Elements the editor produces, including theiframe,embed,object,videoandaudioof its embed plugin, are kept, so existing content keeps working. A<script>element is still refused as before, the rest is removed silently so the member keeps the text. - Escape every user attribute and page name printed into HTML with
htmlspecialchars(), and encode the user name in the JavaScript string withjson_encode(). - Refuse
< > " ' & \ /and control characters in new user names. - Create pages from editor links only when the name survives
validatePagename()unchanged.
Content that is already stored is not cleaned by this patch; it is cleaned when it is saved again. Sanitising on output (for example in dewikify()) could be a follow-up.
Patch
Note: this patch is a suggestion and a starting point, not a finished fix. Please review it and adapt it as you see fit.
diff --git a/system/core/base.php b/system/core/base.php
index 13a7dd0..fe58378 100644
--- a/system/core/base.php
+++ b/system/core/base.php
@@ -474,7 +474,12 @@
&& hypha_getUserByName($username) != $user && !isAdmin()) {
return __('user-exists');
} else if (($username == 'hypha')
- || (strpos($username, 'newUser_') !== false)) {
+ || (strpos($username, 'newUser_') !== false)
+ // The user name ends up in URLs and in
+ // JavaScript string arguments of buttons
+ // (makeAction), so refuse characters that
+ // can break out of those.
+ || preg_match('#[<>"\'&\\\\/\x00-\x1f\x7f]#', $username)) {
return __('cant-use-that-name');
} else {
if ($username) $user->setAttribute('username', $username);
@@ -812,7 +817,7 @@
foreach($langList as $lang) {
if ($lang == $language) $index.= '<span class="language selected">'.$lang.'</span>';
elseif (!$page || array_key_exists($lang, $pageLangList)) {
- if ($page) $index.= '<span class="language"><a href="'.$lang.'/'.$pageLangList[$lang].'">'.$lang.'</a></span>';
+ if ($page) $index.= '<span class="language"><a href="'.htmlspecialchars($lang.'/'.$pageLangList[$lang]).'">'.$lang.'</a></span>';
else $index.= '<span class="language"><a href="index/'.$lang.'">'.$lang.'</a></span>';
}
else $index.= '<span class="language disabled">'.$lang.'</span>';
diff --git a/system/core/document.php b/system/core/document.php
index 08417e5..669e845 100644
--- a/system/core/document.php
+++ b/system/core/document.php
@@ -273,6 +273,95 @@
}
}
+ /*
+ Function: sanitizeHtml
+
+ Removes everything from the children of the given element
+ that can run script in the browser of a visitor: elements
+ that execute or redirect (script, base, meta, ...), event
+ handler attributes (on*), srcdoc, CSS with script, and URL
+ attributes whose scheme is not on an allow-list.
+
+ Elements and attributes the editor produces (including the
+ iframe, embed, object, video and audio of its embed plugin)
+ are kept, so existing content keeps working. Links in the
+ form hypha:<id> as stored by wikify() stay allowed.
+
+ Parameters:
+ $elem - HyphaDomElement whose descendants are cleaned
+ */
+ function sanitizeHtml(HyphaDomElement $elem) {
+ // Removed together with their content. svg and math
+ // are removed because their elements (animate, set,
+ // foreignObject) can set URLs and carry script in ways
+ // the attribute checks below do not cover; the editor
+ // never produces them.
+ $forbiddenElements = ['script', 'style', 'base', 'meta', 'link', 'frame', 'frameset', 'applet', 'template', 'noscript', 'noembed', 'noframes', 'xmp', 'plaintext', 'svg', 'math'];
+ // Attributes that hold a URL. Names are compared
+ // without prefix, so xlink:href counts as href.
+ $urlAttributes = ['href', 'src', 'action', 'data', 'poster', 'background', 'cite', 'longdesc', 'codebase', 'dynsrc', 'lowsrc', 'usemap', 'classid', 'archive'];
+
+ foreach (iterator_to_array($elem->getElementsByTagName('*')) as $node) {
+ // Skip nodes already detached with a removed
+ // ancestor.
+ if (!$node->parentNode)
+ continue;
+ $tag = strtolower(preg_replace('/^.*:/', '', $node->nodeName));
+ if (in_array($tag, $forbiddenElements, true)) {
+ $node->parentNode->removeChild($node);
+ continue;
+ }
+
+ foreach (iterator_to_array($node->attributes) as $attr) {
+ $name = strtolower(preg_replace('/^.*:/', '', $attr->nodeName));
+ if (str_starts_with($name, 'on') || in_array($name, ['srcdoc', 'srcset', 'formaction'], true)) {
+ $node->removeAttributeNode($attr);
+ } else if (in_array($name, $urlAttributes, true)) {
+ if (!sanitizeHtmlIsSafeUrl($attr->value, $tag == 'img' && $name == 'src'))
+ $node->removeAttributeNode($attr);
+ } else if ($name == 'style') {
+ // Browsers ignore comments and
+ // whitespace inside these keywords, so
+ // ignore them here too. CSS escapes
+ // (backslashes) can hide a keyword and
+ // the editor never writes them, so a
+ // style with one is dropped as well.
+ $css = strtolower(preg_replace('%/\*.*?\*/|\s%s', '', $attr->value));
+ foreach (['\\', 'expression(', 'javascript:', 'vbscript:', '-moz-binding', 'behavior:'] as $bad) {
+ if (str_contains($css, $bad)) {
+ $node->removeAttributeNode($attr);
+ break;
+ }
+ }
+ }
+ }
+
+ // A param can hand a URL to an embedded object.
+ if ($tag == 'param' && in_array(strtolower($node->getAttribute('name')), ['src', 'movie', 'url', 'href', 'data', 'code'], true) && !sanitizeHtmlIsSafeUrl($node->getAttribute('value')))
+ $node->removeAttribute('value');
+ }
+ }
+
+ /*
+ Function: sanitizeHtmlIsSafeUrl
+
+ Returns true when the URL is relative or uses an allowed
+ scheme: http, https, mailto, tel and hypha (page links as
+ stored by wikify()). Images may also be inline data:image
+ URLs of raster formats, as pasted into the editor.
+ */
+ function sanitizeHtmlIsSafeUrl(string $url, bool $allowDataImage = false) : bool {
+ // Browsers strip control characters and spaces around
+ // a URL and tabs and newlines inside the scheme, so
+ // strip those before looking at the scheme.
+ $url = strtolower(preg_replace('/[\x00-\x20\x7f]+/', '', $url));
+ if (!preg_match('/^([a-z][a-z0-9+.\-]*):/', $url, $matches))
+ return true; // relative URL
+ if (in_array($matches[1], ['http', 'https', 'mailto', 'tel', 'hypha'], true))
+ return true;
+ return $allowDataImage && preg_match('#^data:image/(png|gif|jpeg|webp);#', $url) === 1;
+ }
+
/*
Class: HTMLForm
@@ -726,7 +815,8 @@
/**
* Ensures that the given field, if not empty, contains
- * no obviously unsafe HTML.
+ * no script: refuses script elements and removes
+ * other unsafe HTML (see sanitizeHtml()).
*
* Normally, you should use the `editor` field to make
* validate() apply this check automatically instead of
@@ -749,6 +839,14 @@
$this->errors[$name] = __('html-field-contains-script');
return false;
}
+
+ // Anything else that can run script (event
+ // handlers, javascript: URLs, ...) is removed
+ // rather than refused, so the member keeps the
+ // rest of the text. Callers read the cleaned
+ // value through dataFor().
+ sanitizeHtml($dom);
+ $this->data[$name] = $dom->getInnerHtml();
return true;
}
diff --git a/system/core/pages.php b/system/core/pages.php
index 786c398..029304c 100644
--- a/system/core/pages.php
+++ b/system/core/pages.php
@@ -568,6 +568,12 @@ EOF;
$page = hypha_getPage($language, $pagename);
+ // Only create pages with names that the regular page
+ // creation would also accept; the name comes from
+ // member-supplied HTML and is printed in page lists.
+ if (!$page && validatePagename($pagename) !== $pagename)
+ return;
+
if (!$page) {
$hyphaXml->lockAndReload();
// recheck just in case it got added in the
diff --git a/system/datatypes/index.php b/system/datatypes/index.php
index e5c743a..e477fe6 100644
--- a/system/datatypes/index.php
+++ b/system/datatypes/index.php
@@ -67,7 +67,7 @@
}
$privatePos = strpos($pagename, '&#;');
if ($privatePos) $pagename = substr($pagename, 0, $privatePos);
- $htmlList[] = '<div class="index-item type_'.$pageListDatatype[$pagename].' '.($privatePos ? 'is-private' : 'is-public').'"><a href="'.$language.'/'.$pagename.'">'.showPagename($pagename).'</a></div>';
+ $htmlList[] = '<div class="index-item type_'.htmlspecialchars($pageListDatatype[$pagename]).' '.($privatePos ? 'is-private' : 'is-public').'"><a href="'.htmlspecialchars($language.'/'.$pagename).'">'.htmlspecialchars(showPagename($pagename)).'</a></div>';
}
$html = '<div class="index">';
diff --git a/system/datatypes/settings.php b/system/datatypes/settings.php
index 3e01998..96e32bf 100644
--- a/system/datatypes/settings.php
+++ b/system/datatypes/settings.php
@@ -56,16 +56,16 @@
$isoLangList = Language::getIsoList();
if (isAdmin() || $hyphaUser->getAttribute('username') == $username) {
$account = hypha_getUserByName($username);
- $this->html->writeToElement('pagename', __('change-account').' `'.$username.'`');
+ $this->html->writeToElement('pagename', __('change-account').' `'.htmlspecialchars($username).'`');
if ($account->getAttribute('rights')=='admin') $this->html->writeToElement('pageCommands', makeButton(__('unadmin'), makeAction('settings', 'settingsUnadmin', $username)));
ob_start();
?>
<table class="section" width="100%">
<tr>
<th><?=__('username')?>:</th>
- <td><input name="settingsUsername" id="settingsUsername" type="text" size="20" value="<?=$account->getAttribute('username')?>" /></td>
+ <td><input name="settingsUsername" id="settingsUsername" type="text" size="20" value="<?=htmlspecialchars($account->getAttribute('username'))?>" /></td>
<th><?=__('fullname')?>:</th>
- <td><input name="settingsFullname" id="settingsFullname" type="text" size="20" value="<?=$account->getAttribute('fullname')?>" /></td>
+ <td><input name="settingsFullname" id="settingsFullname" type="text" size="20" value="<?=htmlspecialchars($account->getAttribute('fullname'))?>" /></td>
</tr>
<tr>
<th><?=__('password')?>:</th>
@@ -75,7 +75,7 @@
</tr>
<tr>
<th><?=__('email')?>:</th>
- <td><input name="settingsEmail" id="settingsEmail" type="text" size="20" value="<?=$account->getAttribute('email')?>" /></td>
+ <td><input name="settingsEmail" id="settingsEmail" type="text" size="20" value="<?=htmlspecialchars($account->getAttribute('email'))?>" /></td>
<th><?=__('interface-language')?>:</th>
<td><select name="settingsUiLang" id="settingsUiLang"><?php foreach($uiLangList as $lang) echo '<option value="'.$lang.'"'.($account->getAttribute('language')==$lang?' selected':'').'>'.$isoLangList[$lang].'</option>'; ?></select></td>
</tr>
@@ -185,9 +185,9 @@
<table class="section">
<tr>
<th><?=__('username')?>:</th>
- <td><input name="settingsUsername" id="settingsUsername" type="text" size="20" value="<?=$user->getAttribute('username')?>" /></td>
+ <td><input name="settingsUsername" id="settingsUsername" type="text" size="20" value="<?=htmlspecialchars($user->getAttribute('username'))?>" /></td>
<th><?=__('fullname')?>:</th>
- <td><input name="settingsFullname" id="settingsFullname" type="text" size="20" value="<?=$user->getAttribute('fullname')?>" /></td>
+ <td><input name="settingsFullname" id="settingsFullname" type="text" size="20" value="<?=htmlspecialchars($user->getAttribute('fullname'))?>" /></td>
</tr>
<tr>
<th><?=__('password')?>:</th>
@@ -197,7 +197,7 @@
</tr>
<tr>
<th><?=__('email')?>:</th>
- <td><input name="settingsEmail" id="settingsEmail" type="text" size="20" value="<?=$user->getAttribute('email')?>" /></td>
+ <td><input name="settingsEmail" id="settingsEmail" type="text" size="20" value="<?=htmlspecialchars($user->getAttribute('email'))?>" /></td>
<th><?=__('interface-language')?>:</th>
<td><select name="settingsUiLang" id="settingsUiLang"><?php foreach($uiLangList as $lang) echo '<option value="'.$lang.'"'.($user->getAttribute('language')==$lang?' selected':'').'>'.$isoLangList[$lang].'</option>'; ?></select></td>
</tr>
@@ -628,22 +628,22 @@
}
ob_start();
?>
-<h3><?=__('personal-settings')?> `<?=$hyphaUser->getAttribute('username')?>`</h3>
+<h3><?=__('personal-settings')?> `<?=htmlspecialchars($hyphaUser->getAttribute('username'))?>`</h3>
<table class="section personal-settings">
<tr>
<th><?=__('username')?>:</th>
- <td><?=$hyphaUser->getAttribute('username')?></td>
+ <td><?=htmlspecialchars($hyphaUser->getAttribute('username'))?></td>
<th><?=__('fullname')?>:</th>
- <td><?=$hyphaUser->getAttribute('fullname')?></td>
+ <td><?=htmlspecialchars($hyphaUser->getAttribute('fullname'))?></td>
</tr>
<tr>
<th><?=__('email')?>:</th>
- <td><?=$hyphaUser->getAttribute('email')?></td>
+ <td><?=htmlspecialchars($hyphaUser->getAttribute('email'))?></td>
<th><?=__('interface-language')?>:</th>
- <td><?=$hyphaUser->getAttribute('language')?></td>
+ <td><?=htmlspecialchars($hyphaUser->getAttribute('language'))?></td>
</tr>
<tr>
- <td colspan="4"><input type="button" class="button edit" value="<?=__('edit')?>" onclick="hypha('settings/user/<?=$hyphaUser->getAttribute('username')?>', '', '');" /><input type="button" class="button quit" value="<?=__('quit')?>" onclick="hypha('settings/quit', '', '');" /></td>
+ <td colspan="4"><input type="button" class="button edit" value="<?=__('edit')?>" onclick="hypha(<?=htmlspecialchars(json_encode('settings/user/'.$hyphaUser->getAttribute('username')))?>, '', '');" /><input type="button" class="button quit" value="<?=__('quit')?>" onclick="hypha('settings/quit', '', '');" /></td>
</tr>
</table>
<h3><?=__('member-list')?></h3>
@@ -675,10 +675,10 @@
echo '<td>'.asterisk($user->getAttribute('rights')=='admin').'</td>';
if ($user->getAttribute('rights') == 'invite') echo '<td colspan="2" style="text-align:center;"><i>'.__('invitation-pending').'</i></td>';
elseif ($user->getAttribute('rights')!='exmember' || isAdmin()) {
- echo '<td>'.$user->getAttribute('username').'</td>';
- echo '<td>'.$user->getAttribute('fullname').'</td>';
+ echo '<td>'.htmlspecialchars($user->getAttribute('username')).'</td>';
+ echo '<td>'.htmlspecialchars($user->getAttribute('fullname')).'</td>';
}
- echo '<td>'.$user->getAttribute('email').'</td>';
+ echo '<td>'.htmlspecialchars($user->getAttribute('email')).'</td>';
if (isAdmin()) {
echo '<td>';
if ($user->getAttribute('rights')!='exmember') {
Testing
php -lon all changed files with PHP 8.2 and 8.5: no syntax errors.sanitizeHtml()on PHP 8.2 and 8.5 with sample input:onclick/onerrorremoved,javascript:links (also with mixed case and an embedded tab) lose theirhref,https:, relative,mailto:andhypha:links unchanged,data/images/...anddata:image/pngimage sources kept,data:text/htmlremoved, a YouTubeiframekept without itssrcdoc,base,metaandsvgremoved,style="color:red"kept and styles withexpression(or CSS escapes removed.HTMLForm::isValid()on PHP 8.5 with an editor field: event handler andjavascript:link removed fromdataFor(),hypha:link kept; a<script>still gives the existing validation error.- User name check:
alice,Jan Jansen,a.b-c_daccepted; names with a quote,<,/,\,&or a tab refused. - To do on a test site: edit a text page with links, images and an embedded video and check that it looks the same after saving; open the page index, the settings member list and the account editor with a full name containing markup characters.
Combined test. The security patches of this series (all but three, which conflict with each other and say so) were also applied together on dev (5dae13a) plus the fixes of #405 and #406, and run in a php:8.2-apache container against a copy of a ~1,330-page data set: anonymous request of every page (1,333 requests) and a logged-in request of every page and its edit view; no failures and no new PHP warnings. php -l was clean on 8.2 and 8.5.
Internal reference: SEC-007
- Ngôn ngữ chính
- JavaScript
- Star
- 5
- Fork
- 9
- Merge trung bình
- 18 giờ 7 phút
- Pull request đã merge (30 ngày)
- 11
Chuẩn bị môi trường
Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của PlanBCode/hypha
-
Component: User interface Impact: Low Level: Easy Pagetype: Peer reviewed article Type: Bug Value: Visitors
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 75/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Impact: Medium Level: Easy Type: Bug Value: Users
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Impact: Low Level: Easy Pagetype: Festival Type: Bug Value: Coders
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 82/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Impact: Medium Level: Easy Pagetype: Mailinglist Type: Bug Value: Users
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 79/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Impact: Low Level: Easy Type: Bug Value: Coders
Độ khó 1/5 1-3 giờ Mức phù hợp với người mới 85/100
PlanBCode/hypha#586 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của PlanBCode/hypha
Issue tương tự
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 88/100
Maintainer thường phản hồi trong vòng 1 ngày
-
help wanted
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 77/100
Aethelgard-Systems/agent-postbox#32 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
SamuelNDCE/plume#42 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
[UI] Theme Provider Props table overflows on mobile viewportsCó thể đã có người làm @SajanBisht đã nhận hôm nay. Đang mởframework/gatsby help wanted kind/bug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
layer5io/layer5#8264 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 3 ngày