Security: stored XSS by members through editor HTML, user attributes and page names
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 5/5
- 見積もり時間
- 1週間以上
- 初心者へのやさしさ
- 35/100
調査の方向性
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.
索引モデルが issue の本文から書いたものです。
説明
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
- 主要言語
- JavaScript
- スター
- 5
- フォーク
- 9
- 平均マージ
- 18時間 7分
- マージ済み PR(30日)
- 11
環境構築
このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
PlanBCode/hypha のほかの issue
-
Impact: Medium Level: Easy Type: Bug Value: Users
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
メンテナーはふだん 1 日以内に返信
-
Impact: Low Level: Easy Pagetype: Festival Type: Bug Value: Coders
難易度 1/5 1時間未満 初心者へのやさしさ 82/100
メンテナーはふだん 1 日以内に返信
-
Impact: Medium Level: Easy Pagetype: Mailinglist Type: Bug Value: Users
難易度 2/5 1〜3時間 初心者へのやさしさ 79/100
メンテナーはふだん 1 日以内に返信
-
Impact: Low Level: Easy Type: Bug Value: Coders
難易度 1/5 1〜3時間 初心者へのやさしさ 85/100
PlanBCode/hypha#586 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
Impact: Medium Level: Easy Type: Bug Usecase: De Stadsbron Value: Visitors
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
PlanBCode/hypha#522 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
PlanBCode/hypha の issue をすべて見る
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 74/100
メンテナーはふだん 1 日以内に返信
-
bug documentation
難易度 1/5 1時間未満 初心者へのやさしさ 88/100
awslabs/visual-asset-management-system#880 ·
メンテナーはふだん 1 日以内に返信
-
04 type: enhancement 33 needs: docs
難易度 2/5 1〜3時間 初心者へのやさしさ 63/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 74/100
メンテナーはふだん 1 日以内に返信
-
possible bug
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
Mintplex-Labs/anything-llm#6889 ·
メンテナーはふだん 1 日以内に返信