Use case:

One my clients required a method for protecting uploaded files using the access rights from Organic Groups. That is, only users who were members of a group should be able to download a file that was attached to a protected node (i.e, a node which had realm = og_subscriber in the node_access table). The kicker was that the client also needed to enable group members to make a node public, along with all the documents attached to that node.

Method

Public/Private Problem: After reading about the public/private files debate, I realized that moving files beneath docroot would not enable an anonymous user to view the files that my client wanted to make public (anonymous users would have to login and get an account). I also had a server limitation which was not going to make it possible to create such a private directory. So I had to find a way to make a public file in the /files directory into a protected resource.

Apache mod_rewrite to the Rescue: After some research, I ran across an article on A List Apart by Till Quack (http://alistapart.com/articles/succeed), which detailed a method to use Apache's mod_rewrite to forward all requests for files beneath a directory to a PHP handler (instead of simply serving the file back to the browser). I investigated. I found a method for using Drupal's .htaccess file to forward all requests for URLs beneath the /files directory to a PHP handler, which did several tasks:

  • Checks the URL for XSS attacks, stripping out single and double quotes, etc..
  • Checks if the URL corresponds to a file in the Drupal files table (i.e., was uploaded by upload.module.
  • Checks if the URL exists as a file on the server's file system
  • Checks if the user has permission to view uploaded files
  • Checks if the user has access rights to the node to which the requested file is attached
  • Checks if the mime type of the file is in an approved list
  • Transfers the file using a safe mime type

Is it Secure?:

In order to make this work, I had to modify both Drupal's root .htaccess file and remove the restrictions in the new .htaccess file in the /files directory, which was a security patch to prevent the execution of scripts inside the /files directory. This second mod made me nervous, so I knew that the onus was on me to craft by PHP handler so as to replicate the protection granted by the .htaccess file in the /files directory. Here is what I devised. I open it to your able eyes and hope that someone can improve my work:

Drupal's .htaccess file

Placed right after the RewriteBase declaration and before Drupal's handler, I wrote a rewrite declaration that does two things: 1) forwards all requests for URLs beneath the files/ directory to a handler in Drupal's root directory called filehandler.php, and 2) set an Apache environment variable to the value of the RewriteBase declaration, as I found that I needed to know the rewrite base in order to process the file request. Here's the one line of code:

  RewriteBase /my_base_dir/ 
  RewriteRule ^files(.*)$  filehandler.php [L,E=REWRITEBASE:/my_base_dir/]

Files/ .htaccess

In the files/ directory, the .htaccess file from the security patch prevented any rewrite declarations from working. It had to go. But I wanted to ensure that no scripts would execute in this directory. Since my server only has PHP I made the following declarations, commenting out those for Python and Perl:

# comment out security directive
#SetHandler Drupal_Security_Do_Not_Remove_See_SA_2006_006
#Options None
#<IfModule mod_rewrite.c>
#  RewriteEngine off
#</IfModule>

RemoveHandler application/x-httpd-php .php
RemoveHandler application/x-httpd-php-source .phps
#RemoveHandler application/x-python .py
#RemoveHandler cgi-script .cgi
#Remove Handler cgi-script .pl

Filehandler Bootstrap and Module

Now that I opened myself up, I needed to close the holes in the new file handler and module. Here's what I did:

filehandler.php (in the Drupal root directory)

**
 * Drupal Bootstrap: won't be called unless we call it here!
 */

require_once './includes/bootstrap.inc';
drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);

/**
 * Initialize variables
 */

  // Error Messages
  define(FILEHANDLER_NOT_FOUND, "File not found.");
  define(FILEHANDLER_NO_ACCESS_UPLOADS, "You do not have access to uploaded files. Please contact the administrator.");                              
  define(FILEHANDLER_NO_ACCESS_NODE, "You do not have access to this file. Please contact the administrator or moderator of the project."); 
	
	// Apache Environment Vars fed from mod_rewrite calls in htaccess file
  $docroot            = $_SERVER["DOCUMENT_ROOT"];
  $requested_uri      = $_SERVER["REQUEST_URI"];
  $this_script        = $_SERVER["SCRIPT_FILENAME"];
  $rewrite_base       = $_SERVER["REDIRECT_REWRITEBASE"];
  

/**
 * Filter the URL for XSS: decode html entities, including single and double quotes, limit to subset of Drupal's allowed protocols
 * Put here as the very first action on the URL to catch bad actors before any other methods act on the string.
 */

  $requested_uri = filehandler_filter_xss_bad_protocol($requested_uri);

/**
 * Download the file
 * 
 * 1. Ensure we are dealing with a path that is not the script itself or a call for an Apache index file
 * 2. Remove any rewrite base from the url, particularly if the Drupal install is in a subdirectory
 * 3. Download the file using filehandler's hook_download to control access, sanity, and mime type
 * 
 */
	
  if(  ($this_script != $docroot.$requested_uri)      // avoid calling this script itself, else infinite loop...
  	   && ($requested_uri != "/")) 							      // avoid calling the index file
  { 
    // strip out the rewrite base, which comes with the requested uri. Important for Drupal installs in subdirectories
  	$url   = str_replace($rewrite_base, "",$requested_uri);
  	
  	// download the file, using the filehandler_download hook to check for if the file exists in the Drupal files table, on the filesystem,
  	// and whether the file is of an authorized mime type, and if the user has access to uploaded files as well as the particular requested file
  	// as defined by the node_access table and the node to which the file is attached.
  	file_download($url);
  	
	} else {
	 // the request was for the script itself or for an index file. Return not found.
	 return drupal_not_found();
	}

filehandler.module

The draft code for the accompanying module is posted here http://drupal.org/node/116843. And yes, I should have posted this module in this forum the first time. Lesson learned.

Humble Request: Is this code secure?

Since I had to work around a security patch, i am hoping that someone will find this code useful and could ponder if this method of downloading a file is secure.

Comments

hbfkf’s picture

Do you really need drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);? Wouldn't it be enough to call drupal_bootstrap(DRUPAL_BOOTSTRAP_SESSION); since we only need to know about the user and access?

I am concerned about speed as I need something like this to serve GIF images (securly).

Big big thank you for the code and the nice documentation!
Kaspar