Hardening BuddyPress Group Documents

The BuddyPress Group Documents plugin allows groups a handy way for users to share documents with fellow members of a BP group. It’s crucial to the work that is done on the CUNY Academic Commons. But, by default, the plugin stores documents in a subdirectory of your WP uploads folder (usually /wp-content/blogs.dir/ on multisite). That means that documents are available directly, to anyone who has the URL, regardless of the public/private/hidden status of groups. This isn’t a problem from within BuddyPress, since URLs for the documents only appear inside of the protected group interface. But if the URL is shared, then the document becomes publicly available. Worse, if someone posts the URL of a document in a public place, search engine bots will find it, and the contents of the document could end up in Google.

I wrote a few helper functions to change this behavior. The strategy is this: Move the files so that they are not accessible via URL, ie in a directory above the web root. (In my case, it’s a directory called bp-group-documents, just above my web root.) Then, catch requests of a certain type (I’ve chosen to go with a URL parameter get_group_doc=), and check them to see whether the current user has the adequate permission to access the document in question. Finally, make sure that all of the URLs and paths that BPGD uses to upload and display documents are filtered to the updated versions. I’ve provided my code below – use and modify at your pleasure. You should be able to place it in your plugins/bp-custom.php file, and then move your existing docs from their current location (probably something like wp-content/blogs.dir/1/files/group-documents) to the new directory.

I also added a line to my .htaccess file to ensure that requests to the old URLs are redirected to the new, hardened URL. That line is this:
[code]
RewriteRule ^wp-content/blogs.dir/1/files/group-documents/(.*) /?get_group_doc=$1 [R,L]
[/code]
Obviously, you may have to modify it for different file paths.

EDITED Feb 8, 2011 to include the code for creating directories when none exist

[code language=”php”]
define( ‘BP_GROUP_DOCUMENTS_SECURE_PATH’, substr( ABSPATH, 0, strrpos( rtrim( ABSPATH, ‘/’ ), ‘/’ ) ) . ‘/bp-group-documents/’ );

function cac_filter_doc_url( $doc_url, $group_id, $file ) {
$url = bp_get_root_domain() . ‘?get_group_doc=’ . $group_id . ‘/’ . $file;
return $url;
}
add_filter( ‘bp_group_documents_file_url’, ‘cac_filter_doc_url’, 10, 3 );

function cac_filter_doc_path( $doc_url, $group_id, $file ) {
$document_dir = BP_GROUP_DOCUMENTS_SECURE_PATH . $group_id . ‘/’;

if ( !is_dir( $document_dir ) )
mkdir( $document_dir, 0755, true );

$path = BP_GROUP_DOCUMENTS_SECURE_PATH . $group_id . ‘/’ . $file;
return $path;
}
add_filter( ‘bp_group_documents_file_path’, ‘cac_filter_doc_path’, 10, 3 );

function cac_catch_group_doc_request() {
$error = false;

if ( empty( $_GET[‘get_group_doc’] ) )
return;

$doc_id = $_GET[‘get_group_doc’];

// Check to see whether the current user has access to the doc in question
$file_deets = explode( ‘/’, $doc_id );
$group_id = $file_deets[0];
$group = new BP_Groups_Group( $group_id );

if ( empty( $group->id ) ) {
$error = array(
‘message’ => ‘That group does not exist.’,
‘redirect’ => bp_get_root_domain()
);
} else {
if ( $group->status != ‘public’ ) {
// If the group is not public, then the user must be logged in and
// a member of the group to download the document
if ( !is_user_logged_in() || !groups_is_user_member( bp_loggedin_user_id(), $group_id ) ) {
$error = array(
‘message’ => sprintf( ‘You must be a logged-in member of the group %s to access this document. If you are a member of the group, please log into the site and try again.’, $group->name ),
‘redirect’ => bp_get_group_permalink( $group )
);
}
}

// If we have gotten this far without an error, then the download can go through
if ( !$error ) {

$doc_path = BP_GROUP_DOCUMENTS_SECURE_PATH . $doc_id;

if ( file_exists( $doc_path ) ) {
$mime_type = mime_content_type( $doc_path );
$doc_size = filesize( $doc_path );

header(“Cache-Control: public, must-revalidate, post-check=0, pre-check=0”);
header(“Pragma: hack”);

header(“Content-Type: $mime_type; name='” . $file_deets[1] . “‘”);
header(“Content-Length: ” . $doc_size );

header(‘Content-Disposition: attachment; filename=”‘ . $file_deets[1] . ‘”‘);
header(“Content-Transfer-Encoding: binary”);
ob_clean();
flush();
readfile( $doc_path );
die();

} else {
// File does not exist
$error = array(
‘message’ => ‘The file could not be found.’,
‘redirect’ => bp_get_group_permalink( $group ) . ‘/documents’
);
}
}
}

// If we have gotten this far, there was an error. Add a message and redirect
bp_core_add_message( $error[‘message’], ‘error’ );
bp_core_redirect( $error[‘redirect’] );
}
add_filter( ‘wp’, ‘cac_catch_group_doc_request’, 1 );

// http://www.php.net/manual/en/function.mime-content-type.php#87856
if(!function_exists(‘mime_content_type’)) {

function mime_content_type($filename) {

$mime_types = array(

‘txt’ => ‘text/plain’,
‘htm’ => ‘text/html’,
‘html’ => ‘text/html’,
‘php’ => ‘text/html’,
‘css’ => ‘text/css’,
‘js’ => ‘application/javascript’,
‘json’ => ‘application/json’,
‘xml’ => ‘application/xml’,
‘swf’ => ‘application/x-shockwave-flash’,
‘flv’ => ‘video/x-flv’,

// images
‘png’ => ‘image/png’,
‘jpe’ => ‘image/jpeg’,
‘jpeg’ => ‘image/jpeg’,
‘jpg’ => ‘image/jpeg’,
‘gif’ => ‘image/gif’,
‘bmp’ => ‘image/bmp’,
‘ico’ => ‘image/vnd.microsoft.icon’,
‘tiff’ => ‘image/tiff’,
‘tif’ => ‘image/tiff’,
‘svg’ => ‘image/svg+xml’,
‘svgz’ => ‘image/svg+xml’,

// archives
‘zip’ => ‘application/zip’,
‘rar’ => ‘application/x-rar-compressed’,
‘exe’ => ‘application/x-msdownload’,
‘msi’ => ‘application/x-msdownload’,
‘cab’ => ‘application/vnd.ms-cab-compressed’,

// audio/video
‘mp3’ => ‘audio/mpeg’,
‘qt’ => ‘video/quicktime’,
‘mov’ => ‘video/quicktime’,

// adobe
‘pdf’ => ‘application/pdf’,
‘psd’ => ‘image/vnd.adobe.photoshop’,
‘ai’ => ‘application/postscript’,
‘eps’ => ‘application/postscript’,
‘ps’ => ‘application/postscript’,

// ms office
‘doc’ => ‘application/msword’,
‘rtf’ => ‘application/rtf’,
‘xls’ => ‘application/vnd.ms-excel’,
‘ppt’ => ‘application/vnd.ms-powerpoint’,

// open office
‘odt’ => ‘application/vnd.oasis.opendocument.text’,
‘ods’ => ‘application/vnd.oasis.opendocument.spreadsheet’,
);

$ext = strtolower(array_pop(explode(‘.’,$filename)));
if (array_key_exists($ext, $mime_types)) {
return $mime_types[$ext];
}
elseif (function_exists(‘finfo_open’)) {
$finfo = finfo_open(FILEINFO_MIME);
$mimetype = finfo_file($finfo, $filename);
finfo_close($finfo);
return $mimetype;
}
else {
return ‘application/octet-stream’;
}
}
}

[/code]

7 thoughts on “Hardening BuddyPress Group Documents

  1. Kate

    I just had this issue come up yesterday. Thanks for posting! Do you know if there is also a way to restrict the group documents by role? We post homework for our students as well as the answers for the parents. Of course we don’t want the students to have the answers. 🙂

    Reply
    1. Boone Gorges Post author

      Hi Kate. Yes, it’s possible to do this, but it will take some custom coding to make it work. First, it depends on how you are distinguishing teachers and parents from students. Are you using a piece of user_meta? Or a BP xprofile field? Or have you defined roles with custom permissions, so that you can use current_user_can()? In any case, you’ll need a conditional statement that tests for this, and if the property does not exist, then return false. Keep in mind that this is only a fix for the download script in this post – the listing of documents on the group’s Documents tab is handled by a different method. You’d have to dig into the code to find out what it is; I am afraid I don’t know it well enough to tell you off the top of my head. If I were you, I would look at the code that the plugin uses to display by category, and repurpose it for your purposes.

      Reply
  2. Mark Pearson

    Boone,
    This is fantastically useful. I take it that the bp-group-documents folder needs world write access to allow Apache to write to it, yes?
    What would it take for all uploads to wp-content/blogs.dir to be stored outside the web root? Would that not make the whole system more secure? Certainly moodle stores it’s data in this way by default.
    Cheers
    Mark

    Reply
  3. Chris

    Hi Boone,

    The test you use on line 033 to see if a group with the provided ID exists doesn’t appear to work.

    When a new BP_Groups_Group instance is created (as you do on line 031) by passing in an non existent ID, the returned BP_Groups_Group object’s ID property still contains the ID you passed in, even though the group doesn’t actually exist.

    Testing the ‘date_created’ property instead appears to work, or do you have any other suggestion?

    Kind regards

    Chris

    Reply
    1. Boone Gorges Post author

      Thanks for pointing this out, Chris. I’ve been following the BP Trac ticket that you opened regarding the issue, and I’ll update this post accordingly when that’s been settled.

      Reply
  4. Benoit

    Merci Boone pour le plugin 🙂
    You know that this plugin was delated from WP directory and it’s now supported here:
    http://lenasterg.wordpress.com/2012/04/17/buddypress-group-documents-for-bp-1-5-and-wp-3-3/

    I tried your code and it seems work but with some files not, sometimes visitors can download the file and sometimes visitors and members are redirected to group home without any error message!
    I am using Bp 1.6.1 and WP 3.4.1 and the plugin above.

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *