'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" )); } }