Extension:SimpleSecurity
Contents
Extension purpose
The general problem is that although the actions one can perform on articles can be restricted easily, the ability to read content cannot be easily restricted on a per-title basis. The reason reading is difficult to restrict is because it's an operation which is not just performed via one action, many different actions, special-pages and extensions access article content and display it in diverse ways.
To allow restrictions on the reading of article content requires a hook at a very low-level in the programming which is common to all the kinds of operations involved in the retrieving of article content. The MediaWiki code is written to support a number of different database server's each with their own implementation of the SQL language, and it has also been designed to allow a wiki's database to be served from many servers concurrently. To achieve this, a Database class has been created to add a unified database interface to the MediaWiki runtime environment. All database interaction is handled via the methods of the Database class, and specifically, the only way that any legitimate MediaWiki code would obtain any article content is through the fetchRow and fetchObject methods.
So to really solve the per-title read-restriction issue, a hook would need to be placed into some of the methods of the Database class. The DatabaseFetchHook extension was created to test if such hooks were a feasible option, and to add these hooks dynamically without modifying any of the MediaWiki code-base files. The results were positive and so now need to be incorporated into a usable security extension.
Extension functionality Overview
The new version is designed to be in line with MediaWiki's own development plans for security. It seems that they won't have a solution to the read problem for some time, but the current method of article protection already allows for restricting edit, create and move actions by group, and allows for the possibility of other actions to be handled. Also, they have a database column prepared for allowing restriction based on individual user as well.
Due to this we'll be dropping the idea on in-line annotations and work instead on extending the current official protection scheme which uses a database table to relate security information to articles. The following points cover the initial development effort to be completed over the next month.
Read permission
Protection form without Simple Security | Protection form with Simple Security | |
---|---|---|
Category & Namespace Restrictions
Category-based permissions are now be handled from LocalSettings.php for efficiency reasons and will not inherit more than a single level. Namespace permissions will now also be supported, both will be defined in the $wgPageRestrictions array which uses a format as follows:
$wgPageRestrictions['Category:Foo']['action1'] = array('group1', 'group2');
$wgPageRestrictions['Namespace:Bar']['action2'] = 'group3';
This example restricts article in Category:Foo such that only members of groups group1 and group2 can perform "action1". And "action2" can only be performed by group3 for all articles in the "Bar" namespace.
- NOTE: the tests for this feature must cover all actions, particularly read
Security information
If the $wgSecurityRenderInfo global is set to true (default), then pages exhibiting restrictions will include a link which can be clicked to show/hide and information box. The information shows three things:
- what restrictions affect the article (if any) from $wgPageRestrictions and the article's category and namespace.
- what restrictions are associated with the title itself from the protect tab.
- what restrictions are currently in effect for the current user and page request.
Unreadable links
If the $wgSecurityAllowUnreadableLinks global is set to false (default), then links to local articles which the current user does not have permission to read are rendered as plain text rather than a hyperlink.
The image above shows a small fragment of a recent changes list. Notice that the Sandbox article title and its diff and hist show up grey and are not links, their style can be set in CSS by addressing the unreadable class attribute.
How the extension works
User rights
Integration with MediaWiki's native permissions system is achieved through the UserGetRights hook. This hook allows the available rights for the user to be filtered based on the directives specified in $wgTitle and $wgGroupPermissions. The UserGetRights hook (introduced in 1.11) is sufficient for all the actions except for read. UserGetRights hook is called from User::getRights and returns an array (list of strings) of the rights the current user has. User::getRights passes User::getEffectiveGroups to User::getGroupPermissions to filter the rights in $wgGroupPermissions.
- NOTE: there can be multiple instances of a single right in the returned list
- UserGetRights hook
- User::getRights
- User::getGroupPermissions( &$group )
Title restrictions
The Title::loadRestrictionsFromRow method loads all the restrictions information for a title from the page_restrictions table into the Title::mRestrictions array which has actions for keys (from pr_type column), and arrays of groups (from pr_level column) as values. Restrictions are obtained publicly via Title::getRestrictions method by passing an action key and getting the groups that are allowed to perform that action in response.
- Title::getRestrictions( $action )
- Title::loadRestrictionsFromRow
- Title::getUserPermissionsErrorsInternal( $action, &$user ) - the main internal processing of title-based permissions
- userCan( $action )
- userCanRead() - checked by Article::View()
Implementing the read restriction
Before implementing anything sophisticated with the DB hook, the standard functionality of blocking actual page views on pages with restricted read action. The obvious way to do this would be to check whether the title had such a restriction early on and then update the rights as if they had been set like that with $wgGroupPermissions['*']['read'] = false.
$wgGroupPermissions defaults problem
The first problem encountered with this method is that it only seems to block viewing of articles by anonymous users, it can't be made to block viewing by logged in users of any group. I.e. when a user is logged in, they can always view an article regardless of any read restrictions in $wgGroupPermissions. Article::View() checks whether the article is allowed to be read with Title::userCanRead() and calls $wgOut->loginToUse() if not. Title::userCanRead() simply calls User::isAllowed('read') which checks if read is in the list of actions returned by getRights.
This problem is due to a performance-based short cut that has been added in Title::userCanRead() which returns true without further processing if $wgGroupPermissions['*']['read'] is set to true (which it is by default). To avoid it, $wgGroupPermissions['*']['read'] is remembered and set to false in the UserGetRights hook, if there are no read restrictions in the title, and the site is public, then the read right is put back again.
System messages
Some of the system messages need to be adjusted to match the more generic context the extension allows for article protection:
- protect-unchain Unlock move permissions (changed to "Modify actions individually")
- badaccess* e.g. The action you have requested is limited to users in one of the groups $1 (wrong groups in $1)
Move problem
The read action when restricted prevents the user from performing most actions on the page such as edit or history, but move must be handled specifically because it's implemented as a special page not a normal page action.
Implementing the source restriction
DatabaseFetchHook
All text content is held in the old_text field of the text table, so the row reading needs to be intercepted. But the SQL queries also have to be adjusted to ensure that the old_id field is available along with old_text because otherwise it cannot be established whether or not the text is allowed to be viewed (since the id is needed to relate the text fragment with a current title and its security directives). The DatabaseFetchHook has been implemented and tested along with the SQL query patch.
Page-Restrictions Table
The current protection form could be extended or replaced to allow all available actions. Here's an example of the permissions tables content:
- Page restrictions table
- $wgRestrictionTypes - array of types available for the pr_type field
- $wgRestrictionLevels - array of groups available for the pr_level field
- Article::updateRestrictions - Revision class and Title class
- ProtectionForm.php
SimpleSecurity 4.x incompatible with MediaWiki 1.17+
An issue has cropped up with MediaWiki 1.17 (bug 29525) which is due to changes in the load-balancer that are incompatible with the DatabaseHook used in SimpleSecurity.
PHP Fatal error: Call to a member function isOpen() on a non-object in LoadBalancer.php on line 652
The cause
This is due to the following statement returning null (in MediaWiki 1.18 this method is changed to DatabaseBase::factory(), but the same problem persists),
$db = DatabaseBase::newFromType( $server['type'], $server );
SimpleSecurity changes the database-type to "SimpleSecurity" which is a new type that extends the original type by replicating it exactly but adding the hooks into the query() and fetchObject() methods. The problem is that the newFromType() and factory() methods use a hard-coded list of available types and return null if the passed type doesn't match any of them,
public final static function newFromType( $dbType, $p = array() ) {
$canonicalDBTypes = array( 'mysql', 'postgres', 'sqlite', 'oracle', 'mssql', 'ibm_db2' );
$dbType = strtolower( $dbType );
if( in_array( $dbType, $canonicalDBTypes ) ) {
$class = 'Database' . ucfirst( $dbType );
return new $class( ... );
} else {
return null;
}
}
Updating the code to make this list globally accessible would be quite simple, but would not be introduced until probably version 1.19, so there would then be two major versions of MediaWiki in use which are incompatible with SimpleSecurity, so we need to figure out a new approach to the DatabaseHook which works with the latest LoadBalancer.
Finding a solution
The bottom line is that we need the wfGetDB() global function to return an instance of database class that has been extended by having our hooks added into the query() and fetchObject() methods. As can be seen in the following snippet from GlobalFunctions.php showing the content of the wfGetDB() function, we actually need to be looking at LBFactory::singleton(),
function &wfGetDB( $db, $groups = array(), $wiki = false ) {
return wfGetLB( $wiki )->getConnection( $db, $groups, $wiki );
}
function wfGetLB( $wiki = false ) {
return wfGetLBFactory()->getMainLB( $wiki );
}
function &wfGetLBFactory() {
return LBFactory::singleton();
}
Here's the content of LBFactory::singleton() which simply checks if a single instance of the LBFactory exists, and creates one if not. It uses the $wgLBFactoryConf global array to select the class to be used for this singleton instance,
static function &singleton() {
if ( is_null( self::$instance ) ) {
global $wgLBFactoryConf;
$class = $wgLBFactoryConf['class'];
self::$instance = new $class( $wgLBFactoryConf );
}
return self::$instance;
}
In DefaultSettings.php we see that the $wgLBFactoryConf global sets the default LBFactory class to LBFactory_Simple with no other paramaters apart from the type of class (i.e. nothing else is sent to the LBFactory_Simple constructor),
$wgLBFactoryConf = array( 'class' => 'LBFactory_Simple' );
The constructor creates a new ChronologyProtector for the singleton which sits idle until getMainLB() is called (which the wfGetLB() global function does), and the first thing getMainLB() does if nothing exists yet is to call newMainLB(),
function newMainLB( $wiki = false ) {
global $wgDBservers, $wgMasterWaitTimeout;
if ( $wgDBservers ) {
$servers = $wgDBservers;
} else {
global $wgDBserver, $wgDBuser, $wgDBpassword, $wgDBname, $wgDBtype, $wgDebugDumpSql;
$servers = array( array( 'host' => $wgDBserver, 'user' => $wgDBuser ... ) );
}
return new LoadBalancer( array( 'servers' => $servers, 'masterWaitTimeout' => $wgMasterWaitTimeout ) );
}
It's the LoadBalancer class that contains the call to DatabaseBase::newFromType()/factory() in its reallyOpenConnection() method which contains the prolematic hard-coded list of available database classes, so the easiest fix would be to adjust the voodoo to change the reallyOpenConnection() so that it handles the creation of the instance itself without performing the check rather than delegating it to the DatabaseBase class.
So the new solution does the following to implement the DBhook,
- Creates a new LBFactory_SimpleSecurity class identical to LBFactory_Simple except that it returns a LoadBalancer_SimpleSecurity object
- Creates a new LoadBalancer_SimpleSecurity class identical to LoadBalancer except that it always returns a Database_SimpleSecurity object regardles of the type defined in $wgDBtype.
- Create a new Database_SimpleSecurity database class which is identical to the class specified by $wgDBtype, but with hooks into its query() and fetchObject() methods. Note that this class has to be defined inside an eval() statement because PHP doesn't allow the class statement to use variables for the name of the new class or the base-class.
- Sets $wgLBFactoryConf to use our LBFactory_SimpleSecurity class instead of the default LBFactory_Simple class.
SimpleSecurity 5.0 released which works for MediaWiki 1.17
Major changes have been made to SimpleSecurity.php and SimpleSecurity_body.php which makes SimpleSecurity 5.x incompatible with versions of MediaWiki before 1.17. I have moved 4.x into the OD extensions repo so that users of these older versions of MediaWiki can still use it.
- Working in 1.17.0
Working in 1.19alpha (r90798)- the extension cannot be trusted beyond 1.17, a new version needs to be written to support recent MediaWiki versions
The code for the new DBhook method
/**
* The new LBFactory_SimpleSecurity class identical to LBFactory_Simple except that it returns a LoadBalancer_SimpleSecurity object
*/
class LBFactory_SimpleSecurity extends LBFactory_Simple {
function newMainLB( $wiki = false ) {
global $wgDBservers, $wgMasterWaitTimeout;
if ( $wgDBservers ) {
$servers = $wgDBservers;
} else {
global $wgDBserver, $wgDBuser, $wgDBpassword, $wgDBname, $wgDBtype, $wgDebugDumpSql;
$servers = array(array(
'host' => $wgDBserver,
'user' => $wgDBuser,
'password' => $wgDBpassword,
'dbname' => $wgDBname,
'type' => $wgDBtype,
'load' => 1,
'flags' => ($wgDebugDumpSql ? DBO_DEBUG : 0) | DBO_DEFAULT
));
}
return new LoadBalancer_SimpleSecurity( array(
'servers' => $servers,
'masterWaitTimeout' => $wgMasterWaitTimeout
));
}
}
/**
* LoadBalancer_SimpleSecurity always returns Database_SimpleSecurity regardles of $wgDBtype
*/
class LoadBalancer_SimpleSecurity extends LoadBalancer {
function reallyOpenConnection( $server, $dbNameOverride = false ) {
if( !is_array( $server ) ) {
throw new MWException( 'You must update your load-balancing configuration. See DefaultSettings.php entry for $wgDBservers.' );
}
$host = $server['host'];
$dbname = $server['dbname'];
if ( $dbNameOverride !== false ) {
$server['dbname'] = $dbname = $dbNameOverride;
}
wfDebug( "Connecting to $host $dbname...\n" );
$db = new Database_SimpleSecurity(
isset( $server['host'] ) ? $server['host'] : false,
isset( $server['user'] ) ? $server['user'] : false,
isset( $server['password'] ) ? $server['password'] : false,
isset( $server['dbname'] ) ? $server['dbname'] : false,
isset( $server['flags'] ) ? $server['flags'] : 0,
isset( $server['tableprefix'] ) ? $server['tableprefix'] : 'get from global'
);
if ( $db->isOpen() ) {
wfDebug( "Connected to $host $dbname.\n" );
} else {
wfDebug( "Connection failed to $host $dbname.\n" );
}
$db->setLBInfo( $server );
if ( isset( $server['fakeSlaveLag'] ) ) {
$db->setFakeSlaveLag( $server['fakeSlaveLag'] );
}
if ( isset( $server['fakeMaster'] ) ) {
$db->setFakeMaster( true );
}
return $db;
}
}
/**
* Create the new Database class with hooks in its query() and fetchObject() methods and use our LBFactory_SimpleSecurity class
*/
function applyDatabaseHook() {
global $wgDBtype, $wgLBFactoryConf;
# Create a new "Database_SimpleSecurity" database class with hooks into its query() and fetchObject() methods
# - hooks are added in a sub-class of the database type specified in $wgDBtype
# - 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
eval( 'class Database_SimpleSecurity extends Database' . ucfirst( $wgDBtype ) . ' {
public function query( $sql, $fname = "", $tempIgnore = false ) {
$patched = preg_replace_callback( "/(?<=SELECT ).+?(?= FROM)/", array("SimpleSecurity", "patchSQL"), $sql, 1 );
return parent::query( $patched, $fname, $tempIgnore );
}
function fetchObject( $res ) {
global $wgSimpleSecurity;
$row = parent::fetchObject( $res );
if( isset( $row->old_text ) ) $wgSimpleSecurity->validateRow( $row );
return $row;
}
}' );
# Make sure our new LBFactory is used which in turn uses our LoadBalancer and Database classes
$wgLBFactoryConf = array( 'class' => 'LBFactory_SimpleSecurity' );
}
See also
- MW:Manual:Preventing access
- MW:Help:User rights - available native MediaWiki rights
- $wgGroupPermissions
- MW:Security issues with authorization extensions
- M:Help:Administration
- Soft Security