From d2feb8c22a91df6bf306320f0bc14034c39c2789 Mon Sep 17 00:00:00 2001 From: Bret McMillan Date: Mon, 10 Nov 2008 16:45:29 -0500 Subject: SimpleSecurity extension for mediawiki --- extensions/SimpleSecurity.php | 491 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 extensions/SimpleSecurity.php diff --git a/extensions/SimpleSecurity.php b/extensions/SimpleSecurity.php new file mode 100644 index 0000000..c8bacc2 --- /dev/null +++ b/extensions/SimpleSecurity.php @@ -0,0 +1,491 @@ + 'Read'); + +# Extra groups available in protection form +$wgSecurityExtraGroups = array(); + +# Extra group permissions rules +$wgPageRestrictions = array(); + +# Put SimpleSecurity's setup function before all others +array_unshift($wgExtensionFunctions, 'wfSetupSimpleSecurity'); + +$wgHooks['LanguageGetMagic'][] = 'wfSimpleSecurityLanguageGetMagic'; +$wgExtensionCredits['parserhook'][] = array( + 'name' => 'SimpleSecurity', + 'author' => '[http://www.organicdesign.co.nz/User:Nad User:Nad]', + 'description' => 'Extends the MediaWiki article protection to allow restricting viewing of article content', + 'url' => 'http://www.mediawiki.org/wiki/Extension:SimpleSecurity', + 'version' => SIMPLESECURITY_VERSION + ); + +# SearchEngine is based on $wgDBtype so must be set before it gets changed to DatabaseSimpleSecurity +# - this may be paranoid now since $wgDBtype is changed back after LoadBalancer has initialised +SimpleSecurity::fixSearchType(); + +# If the database class already exists, add the DB hook now, otherwise wait until extension setup +if (!isset($wgSecurityUseDBHook)) $wgSecurityUseDBHook = false; +if ($wgSecurityUseDBHook && class_exists('Database')) wfSimpleSecurityDBHook(); + +class SimpleSecurity { + + var $guid = ''; + var $cache = array(); + var $info = array(); + + /** + * Constructor + */ + function __construct() { + global $wgParser, $wgHooks, $wgLogTypes, $wgLogNames, $wgLogHeaders, $wgLogActions, $wgMessageCache, + $wgSecurityMagicIf, $wgSecurityMagicGroup, $wgSecurityExtraActions, $wgSecurityExtraGroups, + $wgRestrictionTypes, $wgRestrictionLevels, $wgGroupPermissions, + $wgSecurityRenderInfo, $wgSecurityAllowUnreadableLinks; + + # $wgGroupPermissions has to have its default read entry removed because Title::userCanRead checks it directly + if ($this->default_read = (isset($wgGroupPermissions['*']['read']) && $wgGroupPermissions['*']['read'])) + $wgGroupPermissions['*']['read'] = false; + + # Add our hooks + $wgHooks['UserGetRights'][] = $this; + if ($wgSecurityMagicIf) $wgParser->setFunctionHook($wgSecurityMagicIf, array($this, 'ifUserCan')); + if ($wgSecurityMagicGroup) $wgParser->setFunctionHook($wgSecurityMagicGroup, array($this, 'ifGroup')); + if ($wgSecurityAllowUnreadableLinks) $wgHooks['BeforePageDisplay'][] = $this; + if ($wgSecurityRenderInfo) $wgHooks['OutputPageBeforeHTML'][] = $this; + + # Add a new log type + $wgLogTypes[] = 'security'; + $wgLogNames ['security'] = 'securitylogpage'; + $wgLogHeaders['security'] = 'securitylogpagetext'; + $wgLogActions['security/deny'] = 'securitylogentry'; + + # Extend protection form groups, actions and messages + $wgMessageCache->addMessages(array('protect-unchain' => "Modify actions individually")); + $wgMessageCache->addMessages(array('badaccess-group1' => wfMsg('badaccess-group0'))); + $wgMessageCache->addMessages(array('badaccess-group2' => wfMsg('badaccess-group0'))); + $wgMessageCache->addMessages(array('badaccess-groups' => wfMsg('badaccess-group0'))); + + foreach ($wgSecurityExtraActions as $k => $v) { + if (empty($v)) $v = ucfirst($k); + $wgRestrictionTypes[] = $k; + $wgMessageCache->addMessages(array( "restriction-$k" => $v )); + #$wgGroupPermissions['sysop'][$k] = true; # Ensure sysops have the right to perform this extra action + } + + # Ensure the new groups show up in rights management + # - note that 1.13 does a strange check in the ProtectionForm::buildSelector + # $wgUser->isAllowed($key) where $key is an item from $wgRestrictionLevels + # this requires that we treat the extra groups as an action and make sure its allowed by the user + foreach ($wgSecurityExtraGroups as $k => $v) { + if (empty($v)) $v = ucfirst($k); + $wgRestrictionLevels[] = $k; + $wgMessageCache->addMessages(array( "protect-level-$k" => $v )); + $wgGroupPermissions[$k][$k] = true; + } + } + + /** + * Process the ifUserCan conditional security directive + */ + public function ifUserCan(&$parser, $action, $pagename, $then, $else = '') { + return Title::newFromText($pagename)->userCan($action) ? $then : $else; + } + + /** + * Process the ifGroup conditional security directive + * - evaluates to true if current uset belongs to any of the comma-separated users and/or groups in the first parameter + */ + public function ifGroup(&$parser, $groups, $then, $else = '') { + global $wgUser; + $intersection = array_intersect(array_map('strtolower', split(',', $groups)), $wgUser->getEffectiveGroups()); + return count($intersection) > 0 ? $then : $else; + } + + /** + * Convert the urls with guids for hrefs into non-clickable text of class "unreadable" + */ + public function onBeforePageDisplay(&$out) { + $out->mBodytext = preg_replace_callback( + "|]+title=\"(.+?)\".+?>(.+?)|", + array($this, 'unreadableLink'), + $out->mBodytext + ); + return true; + } + + /** + * Render security info if any restrictions on this title + */ + public function onOutputPageBeforeHTML(&$out, &$text) { + global $wgTitle, $wgUser; + # Render security info if any + if (is_object($wgTitle) && $wgTitle->exists() && count($this->info['LS'])+count($this->info['PR'])) { + + $rights = $wgUser->getRights(); + $wgTitle->getRestrictions(false); + $reqgroups = $wgTitle->mRestrictions; + $sysop = in_array('sysop', $wgUser->getGroups()); + + # Build restrictions text + $itext = "\n"; + + # Add some javascript to allow toggling the security-info + $out->addScript("" + ); + + # Add info-toggle before title and hidden info after title + $link = "".wfMsg('security-info-toggle').""; + $link = "$link"; + $info = "
".wfMsg('security-info', $link)."
\n"; + $text = "$info\n$text"; + } + + return true; + } + + /** + * Callback function for unreadable link replacement + */ + private function unreadableLink($match) { + global $wgUser; + return $this->userCanReadTitle($wgUser, Title::newFromText($match[1]), $error) + ? $match[0] : "$match[2]"; + } + + /** + * User::getRights returns a list of rights (allowed actions) based on the current users group membership + * Title::getRestrictions returns a list of groups who can perform a particular action + * So getRights should filter out any title-based restriction's actions which require groups that the user is not a member of + * - Allows sysop access + * - clears and populates the info array + */ + public function onUserGetRights(&$user, &$rights) { + global $wgGroupPermissions, $wgTitle, $wgRequest, $wgPageRestrictions; + + # Hack to prevent specialpage operations on unreadable pages + if (!is_object($wgTitle)) return true; + $title = $wgTitle; + $ns = $title->getNamespace(); + if ($ns == NS_SPECIAL) { + list($name, $par) = explode('/', $title->getDBkey().'/', 2); + if ($par) $title = Title::newFromText($par); + elseif ($wgRequest->getVal('target')) $title = Title::newFromText($wgRequest->getVal('target')); + elseif ($wgRequest->getVal('oldtitle')) $title = Title::newFromText($wgRequest->getVal('oldtitle')); + } + if (!is_object($title)) return true; # If still no usable title bail + + $this->info['LS'] = array(); # security info for rules from LocalSettings ($wgPageRestrictions) + $this->info['PR'] = array(); # security info for rules from protect tab + $this->info['CR'] = array(); # security info for rules which are currently in effect + $groups = $user->getEffectiveGroups(); + + # Put the anon read right back in $wgGroupPermissions if it was there initially + # - it had to be removed because Title::userCanRead short-circuits with it + if ($this->default_read) { + $wgGroupPermissions['*']['read'] = true; + $rights[] = 'read'; + } + + # Filter rights according to $wgPageRestrictions + # - also update LS (rules from local settings) items to info array + $this->pageRestrictions($rights, $groups, $title, true); + + # Add PR (rules from article's protect tab) items to info array + # - allows rules in protection tab to override those from $wgPageRestrictions + if (!$title->mRestrictionsLoaded) $title->loadRestrictions(); + foreach ($title->mRestrictions as $a => $g) if (count($g)) { + $this->info['PR'][] = array($a, $g, wfMsg('security-desc-PR')); + if (array_intersect($groups, $g)) $rights[] = $a; + } + + # If title is not readable by user, remove the read and move rights + if (!in_array('sysop', $groups) && !$this->userCanReadTitle($user, $title, $error)) { + foreach ($rights as $i => $right) if ($right === 'read' || $right === 'move') unset($rights[$i]); + #$this->info['CR'] = array('read', '', ''); + } + + return true; + } + + /** + * Patches SQL queries to ensure that the old_id field is present in all requests for the old_text field + * otherwise the title that the old_text is associated with can't be determined + */ + static function patchSQL($match) { + if (!preg_match("/old_text/", $match[0])) return $match[0]; + $fields = str_replace(" ", "", $match[0]); + return ($fields == "*" || preg_match("/old_id/", $fields)) ? $fields : "$fields,old_id"; + } + + /** + * Validate the passed database row and replace any invalid content + * - called from fetchObject hook whenever a row contains old_text + * - old_id is guaranteed to exist due to patchSQL method + * - bails if sysop + */ + public function validateRow(&$row) { + global $wgUser; + $groups = $wgUser->getEffectiveGroups(); + if (in_array('sysop', $groups)) return; + + # Obtain a title object from the old_id + $dbr =& wfGetDB(DB_SLAVE); + $tbl = $dbr->tableName('revision'); + $rev = $dbr->selectRow($tbl, 'rev_page', "rev_text_id = {$row->old_id}", __METHOD__); + $title = Title::newFromID($rev->rev_page); + + # Replace text content in the passed database row if title unreadable by user + if (!$this->userCanReadTitle($wgUser, $title, $error)) $row->old_text = $error; + } + + /** + * Return bool for whether or not passed user has read access to the passed title + * - if there are read restrictions in place for the title, check if user a member of any groups required for read access + */ + public function userCanReadTitle(&$user, &$title, &$error) { + $groups = $user->getEffectiveGroups(); + if (!is_object($title) || in_array('sysop', $groups)) return true; + + # Retrieve result from cache if exists (for re-use within current request) + $key = $user->getID().'\x07'.$title->getPrefixedText(); + if (array_key_exists($key, $this->cache)) { + $error = $this->cache[$key][1]; + return $this->cache[$key][0]; + } + + # Determine readability based on $wgPageRestrictions + $rights = array('read'); + $this->pageRestrictions($rights, $groups, $title); + $readable = count($rights) > 0; + + # If there are title restrictions that prevent reading, they override $wgPageRestrictions readability + $whitelist = $title->getRestrictions('read'); + if (count($whitelist) > 0 && !count(array_intersect($whitelist, $groups)) > 0) $readable = false; + + $error = $readable ? "" : wfMsg('badaccess-read', $title->getPrefixedText()); + $this->cache[$key] = array($readable, $error); + return $readable; + } + + /** + * Returns a textual description of the passed list + */ + private function groupText(&$groups) { + $gl = $groups; + $gt = array_pop($gl); + if (count($groups) > 1) $gt = wfMsg('security-manygroups', "".join(", ", $gl)."", "$gt"); + else $gt = "the $gt group"; + return $gt; + } + + /** + * Reduce the passed list of rights based on $wgPageRestrictions and the passed groups and title + * $wgPageRestrictions contains category and namespace based permissions rules + * the format of the rules is [type][action] = group(s) + * also adds LS items and currently active LS to info array + */ + private function pageRestrictions(&$rights, &$groups, &$title, $updateInfo = false) { + global $wgPageRestrictions; + $cats = array(); + foreach ($wgPageRestrictions as $k => $restriction) if (preg_match('/^(.+?):(.*)$/', $k, $m)) { + $type = ucfirst($m[1]); + $data = $m[2]; + $deny = false; + + # Validate rule against the title based on its type + switch ($type) { + + case "Category": + + # If processing first category rule, build a list of cats this article belongs to + if (count($cats) == 0) { + $dbr = &wfGetDB(DB_SLAVE); + $cl = $dbr->tableName('categorylinks'); + $id = $title->getArticleID(); + $res = $dbr->select($cl, 'cl_to', "cl_from = '$id'", __METHOD__, array('ORDER BY' => 'cl_sortkey')); + while ($row = $dbr->fetchRow($res)) $cats[] = $row[0]; + $dbr->freeResult($res); + } + + $deny = in_array($data, $cats); + break; + + case "Namespace": + $deny = $data == $title->getNamespace(); + break; + } + + # If the rule applies to this title, check if we're a member of the required groups, + # remove action from rights list if not (can be mulitple occurences) + # - also update info array with page-restriction that apply to this title (LS), and rules in effect for this user (CR) + if ($deny) { + foreach ($restriction as $action => $reqgroups) { + if (!is_array($reqgroups)) $reqgroups = array($reqgroups); + if ($updateInfo) $this->info['LS'][] = array($action, $reqgroups, wfMsg('security-desc-LS', strtolower($type), $data)); + if (!in_array('sysop', $groups) && !array_intersect($groups, $reqgroups)) { + foreach ($rights as $i => $right) if ($right === $action) unset($rights[$i]); + #$this->info['CR'][] = array($action, $reqgroups, wfMsg('security-desc-CR')); + } + } + } + } + } + + /** + * Updates passed LoadBalancer's DB servers to secure class + */ + static function updateLB(&$lb) { + $lb->closeAll(); + foreach ($lb->mServers as $i => $server) $lb->mServers[$i]['type'] = 'SimpleSecurity'; + } + + /** + * Hack to ensure proper search class is used + * - $wgDBtype determines search class unless already defined in $wgSearchType + * - just copied method from SearchEngine::create() + */ + static function fixSearchType() { + global $wgDBtype, $wgSearchType; + if ($wgSearchType) return; + elseif ($wgDBtype == 'mysql') $wgSearchType = 'SearchMySQL4'; + elseif ($wgDBtype == 'postgres') $wgSearchType = 'SearchPostgres'; + elseif ($wgDBtype == 'oracle') $wgSearchType = 'SearchOracle'; + else $wgSearchType = 'SearchEngineDummy'; + } +} + +/** + * Hook into Database::query and Database::fetchObject of database instances + * - this can't be executed from within a method because PHP doesn't like nested class definitions + * - it needs an eval because the class statement isn't allowed to contain strings + * - the hooks aren't called if $wgSimpleSecurity doesn't exist yet + * - hooks are added in a sub-class of the database type specified in $wgDBtype called DatabaseSimpleSecurity + * - $wgDBtype is changed so that new DB instances are based on the sub-class + * - query method is overriden to ensure that old_id field is returned for all queries which read old_text field + * - only SELECT statements are ever patched + * - fetchObject method is overridden to validate row content based on old_id + */ +function wfSimpleSecurityDBHook() { + global $wgDBtype, $wgSecurityUseDBHook, $wgOldDBtype; + $wgOldDBtype = $wgDBtype; + $oldClass = ucfirst($wgDBtype); + $wgDBtype = 'SimpleSecurity'; + eval("class Database{$wgDBtype} extends Database{$oldClass}".' { + public function query($sql, $fname = "", $tempIgnore = false) { + global $wgSimpleSecurity; + $count = false; + if (is_object($wgSimpleSecurity)) + $patched = preg_replace_callback("/(?<=SELECT ).+?(?= FROM)/", array("SimpleSecurity", "patchSQL"), $sql, 1, $count); + return parent::query($count ? $patched : $sql, $fname, $tempIgnore); + } + function fetchObject(&$res) { + global $wgSimpleSecurity; + $row = parent::fetchObject($res); + if (is_object($wgSimpleSecurity) && isset($row->old_text)) $wgSimpleSecurity->validateRow($row); + return $row; + } + }'); + $wgSecurityUseDBHook = false; +} + +/** + * Register magic words + */ +function wfSimpleSecurityLanguageGetMagic(&$magicWords, $langCode = 0) { + global $wgSecurityMagicIf, $wgSecurityMagicGroup; + $magicWords[$wgSecurityMagicIf] = array($langCode, $wgSecurityMagicIf); + $magicWords[$wgSecurityMagicGroup] = array($langCode, $wgSecurityMagicGroup); + return true; +} + +/** + * Called from $wgExtensionFunctions array when initialising extensions + */ +function wfSetupSimpleSecurity() { + global $wgSimpleSecurity, $wgLanguageCode, $wgMessageCache, $wgSecurityUseDBHook, $wgLoadBalancer, $wgDBtype, $wgOldDBtype; + + # Instantiate the SimpleSecurity singleton now that the environment is prepared + $wgSimpleSecurity = new SimpleSecurity(); + + # If the DB hook couldn't be set up early, do it now + # - but now the LoadBalancer exists and must have its DB types changed + if ($wgSecurityUseDBHook) { + wfSimpleSecurityDBHook(); + if (function_exists('wfGetLBFactory')) wfGetLBFactory()->forEachLB(array('SimpleSecurity', 'updateLB')); + elseif (is_object($wgLoadBalancer)) SimpleSecurity::updateLB($wgLoadBalancer); + else die("Can't hook in to Database class!"); + } + + # Request a DB connection to ensure the LoadBalancer is initialised, + # then change back to old DBtype since it won't be used for making connections again but can affect other operations + # such as $wgContLang->stripForSearch which is called by SearchMySQL::parseQuery + wfGetDB( DB_MASTER ); + $wgDBtype = $wgOldDBtype; + + # Add messages + if ($wgLanguageCode == 'en') { + $wgMessageCache->addMessages(array( + 'security' => "Security log", + 'security-logpage' => "Security log", + 'security-logpagetext' => "This is a log of actions blocked by the [[MW:Extension:SimpleSecurity|SimpleSecurity extension]].", + 'security-logentry' => "", + 'badaccess-read' => "\nWarning: \"$1\" is referred to here, but you do not have sufficient permisions to access it.\n", + 'security-info' => "There are $1 on this article", + 'security-info-toggle' => "security restrictions", + 'security-inforestrict' => "$1 is restricted to $2", + 'security-desc-LS' => "(applies because this article is in the $2 $1)", + 'security-desc-PR' => "(set from the protect tab)", + 'security-desc-CR' => "(this restriction is in effect now)", + 'security-infosysops' => "No restrictions are in effect because you are a member of the sysop group", + 'security-manygroups' => "groups $1 and $2" + )); + } +} + -- cgit