Member-only image access on Contao

Posted by Howard Richardson (comments: 0)

Some of the recent Contao sites I've been working on have been for clients who need the content to be locked down and only available to registered members. Of course Contao lets you designate whole branches of a site to be member-only, but a large security problem here is the fact that the images shown in the member-only sections can still be accessed without logging in, if you know their URL.

Furthermore, even if you were to add some kind of htaccess restriction to the directory containing your private images, you might still find resized copies of these freely accessible in Contao's cache (/system/html). A full solution to this problem, therefore, would not only require a valid login to fetch images from a protected directory, but would also protect any files in the cache directory too.

My solution to this problem is to use Apache mod_rewrite to forward any direct access requests to files in either a secured directory under tl_files, or files in the system cache to scripts which first check whether you are correctly logged in and have the necessary permissions. The scripts use Contao's basic authentication functions to check your login cookies and will spit out the correct file if you meet all the requirements. The filenames which are passed to the scripts are of course thoroughly sanitised to make sure they can't be used to retrieve files in any other directories than the ones allowed.

You'll see the scripts (originally modified from a version at  http://www.contao-community.org/viewtopic.php?p=2570 - now offline) either require you to be a correctly authenticated front end user or be logged into the back end. The assumption is that any back end user will be granted access to all the secured files. This is especially important for the system cache, because it's used in the back-end filemanager to serve up image thumbnails.

Below I've included the code I've used to achieve this. I'd welcome any improvements. I've checked them thoroughly and they're quite secure concerning the filenames that it takes from GET. Any dots or links in filenames are reduced to their real file path and then this is compared with the allowed paths, to decide whether or not the file data should be passed on.

 

IMPORTANT NOTE re .htaccess files in the protected folders: these need to be made unwritable by contao to prevent them from being overwritten or deleted if an administrator uses the "lock/unlock directory" function in Contao file manager. I make my .htaccess belong to root and make it read only to achieve this.

Unfortunately the presence of a .htaccess file in any tl_files directory means that Contao doesn't scan the directory when it comes to things like possible links in the WYISWYG editor.

To do: EITHER: write a patch / plugin for this which overrides the default scanning behaviour in Backend.php and typolib.php OR: move the contents of the .htaccess files into main Apache configuration, where it won't confuse Contao.

 

 

 

.htaccess file for secure directories (place one copy in each secure directory under tl_files):

  IndexIgnore *
  Options -Indexes

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REQUEST_FILENAME} -f
  RewriteBase /
  RewriteRule ^(.*)$ /securefile.php?src=%{REQUEST_URI} [L]
</IfModule>

 

.htaccess file for /system/html:

  IndexIgnore *
  Options -Indexes

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REQUEST_FILENAME} -f
  RewriteBase /
  RewriteRule ^(.*)$ /securecache.php?src=$1 [L]
</IfModule>

 

 

Changes to site root .htaccess:

Find these lines and shorten the list of filetypes that don't get rewritten...

  <FilesMatch "\.(htm|php|js|css|htc|swf|eot|woff|svg|ttf)$">
    RewriteEngine Off
  </FilesMatch>

 Then add at the end of the root .htaccess the rule to redirect the cache

  ## Rewrite rule for protected cache
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REQUEST_FILENAME} -f
  RewriteRule ^system/html/(.*)$ /securecache.php?src=$1 [L]
</IfModule>

 

securecache.php (to go in website root folder):

<?php

    /**
    * Contao webCMS
    * Copyright (C) 2005-2013 Leo Feyer
    *
    * This program is free software: you can redistribute it and/or
    * modify it under the terms of the GNU Lesser General Public
    * License as published by the Free Software Foundation, either
    * version 2.1 of the License, or (at your option) any later version.
    *
    * This program is distributed in the hope that it will be useful,
    * but WITHOUT ANY WARRANTY; without even the implied warranty of
    * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
    * Lesser General Public License for more details.
    *
    * You should have received a copy of the GNU Lesser General Public
    * License along with this program. If not, please visit the Free
    * Software Foundation website at http://www.gnu.org/licenses/.
    *
    * PHP version 5
    * @copyright  Howard Richardson
    * @author     Howard Richardson <sequential@sqtl.co.uk>
    * @package    Frontend
    * @license    LGPL
    * @filesource
    * 
    * Extra cache file push script
    * Adapted from http://www.contao-community.org/viewtopic.php?p=2570 (now offline)
    * Authenticates and then sends cached file from $_GET['src'] to browser
    * Checks for bad paths, etc
    * 
    */


    /**
    * Initialize the system
    */
    define('TL_MODE', 'BE');
    require('system/initialize.php');
    require('system/libraries/File.php');



    class Accessor extends Frontend
    {

       /**
        * Initialize the object
        */
       public function __construct()
       {

          // Load user object before calling the parent constructor
          $this->import('FrontendUser', 'User');
          parent::__construct();

          // Check whether a user is logged in
          define('BE_USER_LOGGED_IN', $this->getLoginStatus('BE_USER_AUTH'));
          define('FE_USER_LOGGED_IN', $this->getLoginStatus('FE_USER_AUTH'));

          // HOOK: trigger recall extension
          if (!FE_USER_LOGGED_IN && $this->Input->cookie('tl_recall_fe') && in_array('recall', $this->Config->getActiveModules()))
          {
             Recall::frontend($this);
          }
       }


       /**
        * Run the controller
        */
       public function run()
       {
       
       // Sanitize source file 
          $src = $this->Input->get('src');
	  $src = realpath('system/html/'.$src); // place file in system cache directory and check it exists
	  
	  
	  // if file not found or accessible
	  if (strlen($src) == 0 ) {
	  	header('HTTP/1.1 404 Not Found');
		die("404: No such file");
	  }
	  $cwd = getcwd ();
	  $src = str_replace ($cwd.'/','',$src); // remove base path component from whole real path
	 
	          
          // Limit downloads to the cache directory
          $match = '/^system\/html\//';
          
	if (! preg_match( $match , $src ))
		{
			header('HTTP/1.1 401 Unauthorized');
			die("401: Unauthorized");
	}
          
	
          // Authenticate the current user (either front or back end)
          
          
          if (!$this->User->authenticate() && !BE_USER_LOGGED_IN)
          {
			header('HTTP/1.1 401 Unauthorized');
			die("401: Unauthorized");
          }
          
             
          $file = new File($src);
          // Send it as a file.

         
             // if the file is an image, just return its content
     		 // otherwise, send it as a file.
      if ($file->isGdImage) {
         // Retrieve the mime type.
         Header("Content-Type: " . $file->mime);
         header('Content-Length: ' . $file->filesize);
         // Output the file data.

         
         $resFile = fopen(TL_ROOT . '/' . $src, 'rb');
	fpassthru($resFile);
	fclose($resFile);
         
      } else {
         $this->sendFileToBrowser($src);
      }
         
         
       }
    }

    /**
    * Instantiate controller
    */
    $objIndex = new Accessor();
    $objIndex->run();

    ?>

 

securefile.php (to also go in website root folder):

<?php

    /**
    * Contao webCMS
    * Copyright (C) 2005-2013 Leo Feyer
    *
    * This program is free software: you can redistribute it and/or
    * modify it under the terms of the GNU Lesser General Public
    * License as published by the Free Software Foundation, either
    * version 2.1 of the License, or (at your option) any later version.
    *
    * This program is distributed in the hope that it will be useful,
    * but WITHOUT ANY WARRANTY; without even the implied warranty of
    * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
    * Lesser General Public License for more details.
    *
    * You should have received a copy of the GNU Lesser General Public
    * License along with this program. If not, please visit the Free
    * Software Foundation website at http://www.gnu.org/licenses/.
    *
    * PHP version 5
    * @copyright  Howard Richardson
    * @author     Howard Richardson <sequential@sqtl.co.uk>
    * @package    Frontend
    * @license    LGPL
    * @filesource
    * 
    * Extra secure file push script
    * Adapted from http://www.contao-community.org/viewtopic.php?p=2570 (now offline)
    * Authenticates and then sends file from $_GET['src'] to browser
    * Checks for bad paths, etc
    * 
    */

    /**
    * Initialize the system
    */
    define('TL_MODE', 'BE');
    require('system/initialize.php');
    require('system/libraries/File.php');


    class Accessor extends Frontend
    {

       /**
        * Initialize the object
        */
       public function __construct()
       {

          // Load user object before calling the parent constructor
          $this->import('FrontendUser', 'User');
          parent::__construct();

          // Check whether a user is logged in
          define('BE_USER_LOGGED_IN', $this->getLoginStatus('BE_USER_AUTH'));
          define('FE_USER_LOGGED_IN', $this->getLoginStatus('FE_USER_AUTH'));

          // HOOK: trigger recall extension
          if (!FE_USER_LOGGED_IN && $this->Input->cookie('tl_recall_fe') && in_array('recall', $this->Config->getActiveModules()))
          {
             Recall::frontend($this);
          }
       }


       /**
        * Run the controller
        */
       public function run()
       {
       // Sanitize source file path
          $src = $this->Input->get('src');
          
          $src=html_entity_decode($src);
          
          
	  $src = realpath(substr($src,1)); // cut off leading slash
	  
	  if (strlen($src) == 0 ) {
	  	header('HTTP/1.1 404 Not Found');
		die("404: No such file");
	  }
	  $cwd = getcwd ();
	  $src = str_replace ($cwd.'/','',$src); // remove base path component from whole real path
	 
	          
          // Limit downloads to the tl_files directory
          $match = '/^'.$GLOBALS['TL_CONFIG']['uploadPath'].'\//';
          
	if (! preg_match( $match , $src ))
		{
			header('HTTP/1.1 401 Unauthorized');
			die("401: Unauthorized");
	}
          
	
          // Authenticate the current user (either front or back end)
          if (!$this->User->authenticate() && !BE_USER_LOGGED_IN)
          {
             header('HTTP/1.1 401 Unauthorized');
	     die("401: Unauthorized");
          }
          
             
          $file = new File($src);
          // Send it as a file.

         
             // if the file is an image, just return its content
     		 // otherwise, send it as a file.
      if ($file->isGdImage) {
         // Retrieve the mime type.
         Header("Content-Type: " . $file->mime);
         header('Content-Length: ' . $file->filesize);
         // Output the file data.
         //readfile($src);
         
         $resFile = fopen(TL_ROOT . '/' . $src, 'rb');
	fpassthru($resFile);
	fclose($resFile);
         
      } else {
         $this->sendFileToBrowser($src);
      }
         
         
       }
    }

    /**
    * Instantiate controller
    */
    $objIndex = new Accessor();
    $objIndex->run();

    ?>

Go back

Add a comment