Use case:

One my clients required a method for protecting uploaded files using the access rights from Organic Groups. 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 og_subscriber set as the realm value 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 expect 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:

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

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


/**
 * Implementation of hook_help().
 */
function filehandler_help($section) {
  switch ($section) {
    case 'admin/modules#description':
      return t('<strong>Filehander:</strong> Provides a method of downloading files from the /files directory using node_access to determine user access. <em>Note: Requires .htaccess modification in both root and /files directories</em>');
  }
}

/**
 * filehandler_download
 * 
 * A hook onto file_download to check if the file exists in the files table and on the filesystem, 
 * that the user has access to the node to which the file is attached (via node_access), and 
 * to return an object with the file's name, path, mime type, or to return -1 for no access or not found
 *
 * @param object $file
 * @return object $file or -1
 */

function filehandler_file_download($file) {
  
  $file = filehandler_create_path($file); // makes sure that the file is in the files directory
                             
/**
 * Security Note: db_query calls _db_query_callback, which calls mysqli_real_escape_string to neutralize SQL injection attacks, casting $file to a string and escaping
 * nefarious characters.
 */
  $result = db_query("SELECT f.* FROM {files} f WHERE filepath = '%s'", $file);
  
  if ($file = db_fetch_object($result)) {
    
    // check if the file is in the allowed mimetypes array
    // replace this array defintion with a variable_get call from the filehandler modules settings, once in module form
    
    // Allowed MIME types: don't clown around with mimes
    $allowed_mimetypes  = array('application/pdf',
                                'application/postscript',
                                'application/rdf+xml',
                                'application/vnd.groove-vcard',
                                'application/vnd.lotus-1-2-3',
                                'application/vnd.lotus-approach',
                                'application/vnd.lotus-freelance',
                                'application/vnd.lotus-notes',
                                'application/vnd.lotus-organizer',
                                'application/vnd.lotus-screencam',
                                'application/vnd.lotus-wordpro',
                                'application/vnd.ms-excel',
                                'application/vnd.ms-powerpoint',
                                'application/vnd.ms-project',
                                'application/msword',
                                'application/vnd.ms-works',
                                'application/vnd.visio',
                                'application/x-gzip',
                                'application/xhtml+xml',
                                'application/xml',
                                'application/zip',
                                'image/gif',
                                'image/jpeg',
                                'image/png',
                                'image/svg+xml',
                                'image/tiff',
                                'text/calendar',
                                'text/html',
                                'text/plain',
                                'text/richtext',
                                'text/rtf',
                                'text/tab-separated-values',
                                'text/xml'
                                ); 
      
      if (!in_array($file->filemime, $allowed_mimetypes))	{
    	 return -1;
    	}   
    
    // check user access to uploaded files from permissions
    if (user_access('view uploaded files')) {
      $node = node_load($file->nid);
      
      // check if the user has access to this particular node
      if (node_access('view', $node)) {
        $file->name     = mime_header_encode($file->filename);
        $file->filemime = mime_header_encode($file->filemime);
        // Special Handling for executables and images
        // Serve images and text inline for the browser to display rather than download.
        // handle special mimetypes: html, xhtml
        $disposition = ereg('^(text/|image/)', $file->filemime) ? 'inline' : 'attachment';
        return array(
          'Content-Type: '. $file->filemime .'; name='. $file->name,
          'Content-Length: '. $file->filesize,
          'Content-Disposition: '. $disposition .'; filename='. $file->name
        );
      }
      else {
        return -1; // no access
      }
    }
    else {
      return -1; // no access
    }
  } 
  // if we got here, the file was not found in the database is not attached to a node
  return -1; // not found in database
}

function filehandler_create_path($dest = 0) {
  $file_path = file_directory_path();
  if (!$dest) {
    return $file_path;
  }
  // file_check_location() checks whether the destination is inside the Drupal files directory.
  if (file_check_location($dest, $file_path)) {
    return $dest;
  }
  // check if the destination is instead inside the Drupal temporary files directory.
  else if (file_check_location($dest, file_directory_temp())) {
    return $dest;
  }
  // Not found, try again with prefixed directory path.
  else if (file_check_location($file_path . '/' . $dest, $file_path)) {
    return $file_path . '/' . $dest;
  }
  // File not found.
  return FALSE;
}

function filehandler_filter_xss_bad_protocol($string, $decode = TRUE) {
  static $allowed_protocols;
  if (!isset($allowed_protocols)) {
    $allowed_protocols = array_flip(variable_get('filter_allowed_protocols', array('http', 'https', 'ftp', 'sftp','webcal')));
  }

  // Get the plain text representation of the attribute value (i.e. its meaning)
  if ($decode) {
    $string = decode_entities($string);
  }
  // Iteratively remove any invalid protocol found.
  do {
    $before = $string;
    $colonpos = strpos($string, ':');
    if ($colonpos > 0) {
      // We found a colon, possibly a protocol. Verify.
      $protocol = substr($string, 0, $colonpos);
      // If a colon is preceded by a slash, question mark or hash, it cannot
      // possibly be part of the URL scheme. This must be a relative URL,
      // which inherits the (safe) protocol of the base document.
      if (preg_match('![/?#]!', $protocol)) {
        break;
      }
      // Check if this is a disallowed protocol
      if (!isset($allowed_protocols[$protocol])) {
        $string = substr($string, $colonpos + 1);
      }
    }
  } while ($before != $string);
  return check_plain($string);
}

Humble Request: Is it secure?

Could someone let me know if this method of downloading a file is secure, and if it might help solve the debate over the public/private downloads? Many thanks for your able eyes.— John

Comments

headkit’s picture

i am very interested in your experience about that hack. did it work? is it secure?
thanx!

volunteermama’s picture

its a year later. Did this go anywhere? Thanks!