How to restrict attachment download to a specific user?

I have a very specific use case where the site built for a lawyer and each of his clients can login to their own ‘specific page/portal’ (custom post type) without the ability to access wp-admin etc. (I created all the login/register/profile-editing pages in the front end). In this page/portal the lawyer will leave messages and files for the client to download, now theoretically speaking, one client can guess (or if has knowledge of another client’s files) other file names and download then thus creating an issue with privacy/security/confidential material etc.

I’m looking for ideas/concepts for a solution, my initial thought was to have the download link point to some download.php sending the attachment id, user id, page/portal id and nonce and the on the other end processing that..

Read More

what do you think? am I on the right track or this approach is flawed?

Thanks!

Related posts

Leave a Reply

5 comments

  1. What needs to happen is that you need to proxy download requests for the file types you want through WordPress. Let’s assume you’re going to restrict access to “.doc” files.

    1. Define a query variable that indicates the requested file

    function add_get_file_query_var( $vars ) {
        $vars[] = 'get_file';
        return $vars;
    }
    add_filter( 'query_vars', 'add_get_file_query_var' );
    

    2. Update .htaccess to forward requests for restricted files to WordPress

    This will capture requests to the files you want to restrict and send them back to WordPress using the custom query variable above. Insert the following rule before the RewriteCond lines.

    RewriteRule ^wp-content/uploads/(.*.docx)$ /index.php?get_file=$1
    

    3. Capture the requested file name in custom query variable; and verify access to the file:

    function intercept_file_request( $wp ) {
        if( !isset( $wp->query_vars['get_file'] ) )
            return;
    
        global $wpdb, $current_user;
    
        // Find attachment entry for this file in the database:
        $query = $wpdb->prepare("SELECT ID FROM {$wpdb->posts} WHERE guid='%s'", $_SERVER['REQUEST_URI'] );
        $attachment_id = $wpdb->get_var( $query );
    
        // No attachment found. 404 error.  
        if( !$attachment_id ) {
            $wp->query_vars['error'] = '404';
            return;
        }
    
        // Get post from database 
        $file_post = get_post( $attachment_id );
        $file_path = get_attached_file( $attachment_id );
    
        if( !$file_post || !$file_path || !file_exists( $file_path ) ) {
            $wp->query_vars['error'] = '404';
            return;
        }
    
        // Logic for validating current user's access to this file...
        // Option A: check for user capability
        if( !current_user_can( 'required_capability' ) ) {
            $wp->query_vars['error'] = '404';
            return;
        }
    
        // Option B: check against current user
        if( $current_user->user_login == "authorized_user" ) {
            $wp->query_vars['error'] = '404';
            return;
        }
    
        // Everything checks out, user can see this file. Simulate headers and go:
        header( 'Content-Type: ' . $file_post->post_mime_type );
        header( 'Content-Dispositon: attachment; filename="'. basename( $file_path ) .'"' );
        header( 'Content-Length: ' . filesize( $file_path ) );
    
        echo file_get_contents( $file_path );
        die(0);
    }
    add_action( 'wp', 'intercept_file_request' );
    

    NB This solution works for single-site installs only! This is because WordPress MU already forwards uploaded file requests in sub-sites through wp-includes/ms-files.php. There is a solution for WordPress MU as well, but it’s a bit more involved.

  2. I’ve recently had a related problem and wrote this article about it.

    I’ll assume that the downloads are uploaded via WordPress’ media handling – or otherwise you have an attachment ID for the download.

    Outline of solution

    • Make the uploads directory ‘secure’ ( In this sense I just mean use .htaccess to block any attempt to directly access of files in uploads directory (or a sub-directory thereof) – e.g. via mysite.com/wp-content/uploads/conf/2012/09/myconfidentialfile.pdf)
    • Create a download link including the attachment ID – this goes through WordPress to check the user’s permission to view the attachment allows/denies access.

    Caveats

    • This makes use of .htaccess to provide security. If this isn’t available / turned on (nginx servers for example), then you won’t get much security. You can prevent the user browsing the uplods directory. But direct access will work.
    • As per above. This should not be used in distribution if you require absolute security. It is fine if your specific set up works – but in general, it can’t be guaranteed. My linked article is in part trying to address this.
    • You will loose thumbnails. Blocking direct access to a folder or sub-folder will mean that thumbnails of files in that folder cannot be viewed. My linked article is in part attempting to address this.

    Blocking direct access

    To do this in your uploads folder (or a subfolder – all confidential material must reside, at any depth, inside this folder). Place a .htaccess file with the following:

    Order Deny,Allow
    Deny from all
    

    In the following I’m assuming that you’ll be attaching confidential material to post type ‘client’. Any media uploaded on on the client-edit page will be stored in the uploads/conf/ folder

    The function to setup the protected uploads directory

    function wpse26342_setup_uploads_dir(){
    
        $wp_upload_dir = wp_upload_dir();
        $protected_folder = trailingslashit($wp_upload_dir['basedir']) . 'conf';    
    
        // Do not allow direct access to files in protected folder
        // Add rules to /uploads/conf/.htacess
        $rules = "Order Deny,Allown";
        $rules .= "Deny from all";
    
        if( ! @file_get_contents( trailingslashit($protected_folder).'.htaccess' ) ) {
                //Protected directory doesn't exist - create it.
            wp_mkdir_p( $protected_folder);
        }
        @file_put_contents( trailingslashit($protected_folder).'.htaccess', $rules );
    
         //Optional add blank index.php file to each sub-folder of protected folder.
    }
    

    Uploading confidential material

       /**
        * Checks if content is being uploaded on the client edit-page
        * Calls a function to ensure the protected file has the .htaccess rules
        * Filters the upload destination to the protected file
        */
        add_action('admin_init', 'wpse26342_maybe_change_uploads_dir', 999);
        function wpse26342_maybe_change_uploads_dir() {
            global $pagenow;
    
            if ( ! empty( $_POST['post_id'] ) && ( 'async-upload.php' == $pagenow || 'media-upload.php' == $pagenow ) ) {
                    if ( 'client' == get_post_type( $_REQUEST['post_id'] ) ) {
                           //Uploading content on the edit-client page
    
                           //Make sure uploads directory is protected
                           wpse26342_setup_uploads_dir();
    
                           //Change the destination of the uploaded file to protected directory.
                           add_filter( 'upload_dir', 'wpse26342_set_uploads_dir' );
                    }
            }
    
        }
    

    Having done that, uploaded content should be inside uploads/conf and trying to access it directly using your browser should not work.

    Downloading Content

    This is easy. The download url can be something www.site.com?wpse26342download=5 (where 5 is the attachment ID of the uploaded content). We use this to identify the attachment, check permissions of the current user and allow them to download.

    First, set up the query variable

    /**
     * Adds wpse26342download to the public query variables
     * This is used for the public download url
     */
    add_action('query_vars','wpse26342_add_download_qv');
    function wpse26342_add_download_qv( $qv ){
        $qv[] = 'wpse26342download';
        return $qv;
    }}
    

    Now set up a listener to (maybe) trigger the download…

    add_action('request','wpse26342_trigger_download');
    function wpse26342_trigger_download( $query_vars ){
    
            //Only continue if the query variable set and user is logged in...
        if( !empty($query_vars['wpse26342download']) && is_user_logged_in() ){
    
            //Get attachment download path
            $attachment = (int) $query_vars['wpse26342download'];
            $file = get_attached_file($attachment);
    
            if( !$file )
                 return;
    
            //Check if user has permission to download. If not abort.       
            header('Content-Description: File Transfer');
            header('Content-Type: application/octet-stream');
            header('Content-Disposition: attachment; filename='.basename($file));
            header('Content-Transfer-Encoding: binary');
            header('Expires: 0');
            header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
            header('Pragma: public');
            header('Content-Length: ' . filesize($file));
    
            ob_clean();
            flush();
            readfile($file);
            exit();
        }
        return $query_vars;
    }
    

    Final comments

    The code above may contain bugs/syntax errors and is untested, and you use it at your own risk :).

    The download url can be ‘prettified’ using rewrites. As stated in the comments you can add an blank index.php inside every child of the protected folder to prevent browsing – but this should be prevented by the .htaccess rules anyway.

    A more secure method would be to store the public files outside of a public directory. Or on an external service like Amazon S3. For the latter you’ll need to generate a valid url to fetch the file from Amazon (using your private key). Both of these require a certain level of trust in your Host / third party service.

    I would be wary about using any plug-ins that suggest they offer ‘protected downloads’. I’ve not found any that provide good enough security. Please not the caveats of this solution too – and I’d welcome any suggestions or criticisms.

  3. Probably, you might have known this trick, This code will check for current logged in user’s username and if it match it will show download link to that file, else it won’t show anything.

    here is the code :

    <?php 
        global $current_user;
        get_currentuserinfo();
    
        if ( 'username' == $current_user->user_login ) {
            echo 'Download Link';
        } else {
            // nothing
        }
    ?>
    

    However, this will not be a good approach, as files are stored on servers, anyone with link can download that file.

  4. I presume this information is confidential and therefore in addition to hiding the links to the files you’ll want to actually make them inaccessible completely to anyone on the web, even if they were to guess the URL, unless that user has explicit permission to download the files.

    Look into storing the files on Amazon S3 securely and then providing pre signed (time limited) URLs to the file provided the correct security checks have been satisfied (i.e. the user has logged into your site and is who they say they are).

    There is a very good AWS SDK that makes it very straight forward to do this.

    What you’ll need to research is how to send files uploaded through the WP upload interface into S3 instead, alternatively build your own uploader.

    Another options would be too look into the code of WP e-commerce. They offer secure download of software files (e.g. MP3s). I believe the files are converted into hashes with an encryption key that is generated per user on purchase. This would take a some deciphering to see how it worked, but the process will not be unique to this plugin so other examples will be available (somewhere).