Skip to content

Instantly share code, notes, and snippets.

@firxworx
Created October 31, 2018 03:58
Show Gist options
  • Save firxworx/eb004e78abe3fc1ccdd2770e7edfcc65 to your computer and use it in GitHub Desktop.
Save firxworx/eb004e78abe3fc1ccdd2770e7edfcc65 to your computer and use it in GitHub Desktop.
Restrict access to files in the WordPress uploads folder so they can only be accessed by logged-in users
# The following Apache .htaccess snippet should be included at the top of the .htaccess file in WordPress' root directory.
#
# Use this option only if you wish to manually modify the .htaccess file. To fully contain file restriction functionality
# inside a plugin you can use the mod_rewrite_rules filter as demonstrated in a commented-out block in plugin-snippet.php
#
# Note <IfModule mod_rewrite.c></IfModule> test is intentionally omitted to trigger an error if mod_rewrite is not available.
# BEGIN RestrictFileExample
# it is ok if WordPress also calls these two directives later on in the file
RewriteEngine On
RewriteBase /
# force ssl (optional per your environment -- may need to be excluded in your dev environment)
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# rewrite all requests for regular files with nontrivial size to pass the filename as the fx_target_file arg
RewriteCond %{REQUEST_FILENAME} -s
RewriteRule ^wp-content/uploads/(.*)$ /index.php?fx_target_file=$1 [QSA,L]
# END RestrictFileExample
/**
* The following functions are provided for example purposes and should ideally be encapsulated
* within a class and/or used with a namespace.
*/
/**
* Add query var `fx_target_file` for restricted file access
*
* filter: query_vars
*/
function addFileQueryVarsFilter( $query_vars ) {
$query_vars[] = 'fx_target_file';
return $query_vars;
}
add_filter( 'query_vars', 'addFileQueryVarsFilter' );
/**
* Disrupt the WordPress' load process if the `fx_target_file` query var is set: include restrict.php and exit.
* Note that `parse_request` runs before `parse_query` so get_query_var() will not be available within restrict.php
*
* action: parse_request
*/
function restrictedFileRequestAction( $wp ) {
if ( array_key_exists( 'fx_target_file', $wp->query_vars ) ) {
include( dirname( plugin_dir_path( __FILE__ ) ) . '/restrict.php' );
exit();
}
}
add_action( 'parse_request', 'restrictedFileRequestAction' );
/**
* The following demonstrates how to modify WordPress' .htaccess file and add the required rewrite rules from within a plugin.
* The mod_rewrite_rules filter is invoked after WordPress has generated its rules and before they are written to the .htaccess file.
* Note that you must hard flush permalinks in order to write/commit these rules to the .htaccess file.
* Flushing permalinks is an expensive operation and should not be done for every request.
* For maximum portability use wp_upload_dir() rather than hard-code the uploads folder per the example below.
*
* function prependFileHtaccessRules( $rules ) {
* $inject = <<<"EOD"
* \n## Plugin Restrict File Access ##
* RewriteEngine On
* RewriteBase /
* RewriteCond %{REQUEST_FILENAME} -s
* RewriteRule ^wp-content/uploads/(.*)$ /index.php?fx_target_file=$1 [QSA,L]
* ## Plugin Restrict File Access ##\n
* EOD;
* return $inject . $rules;
* }
* add_filter( 'mod_rewrite_rules', 'prependFileHtaccessRules' );
*/
<?php
/**
* restrict.php
*
* Protect files in the WordPress uploads folder so they can only be accessed/downloaded by logged-in users.
*
* This gist can be easily customized to more tailored access restrictions (e.g. user role/capability).
* ETAG is intentionally not supported for security.
*
* This gist example improves upon other methods seen online because it can be fully implemented within a self-contained
* plugin including this file (restrict.php) and writing the required .htaccess rules.
*
* It also proposes a more secure approach than flawed methods proposed in other snippets that I've seen, such as
* when 'secure' code only checks for the existence of a cookie (among others).
*
* Creating this gist was inspired by a StackExchange topic:
* @link http://wordpress.stackexchange.com/questions/37144/protect-wordpress-uploads-if-user-is-not-logged-in
* It was also inspired by an implementation by @hakre and the numerous commenters on the associated gist:
* @link https://gist.github.com/hakre/1552239
* A chunk of @hakre's implementation is sourced from the following WP core file: /wp-includes/ms-files.php
*
* October 2018
*
* @author Kevin Firko
* @license GPL-3.0+
*
*/
// turn off output buffering (if on) to prevent exhausting memory when handling large files
if ( ob_get_level() ) { // see 'Notes' for the readfile() function in php docs
ob_end_clean();
}
// verify the request is from a logged-in user
// you can customize access restrictions/permissions here, e.g. test current_user_can( 'example_capability' );
if (! is_user_logged_in() ) {
wp_die( 'Unauthorized', 401 );
}
// confirm WordPress has a valid upload directory
$uploadDir = wp_upload_dir();
if ( empty( $uploadDir ) || !empty( $uploadDir['error'] ) ) {
status_header( 500 );
exit;
}
// determine target filename (note use of $_GET because get_query_var() is only available later in WP's loading sequence)
$basedir = $uploadDir['basedir'];
$file = $_GET['fx_target_file'];
$file = rtrim( $basedir, '/' ) . '/' . $file;
$fileRealPath = realpath( $file );
// confirm the inferred requested file path and fileRealPath match (re potential directory traversal attacks)
if ( $fileRealPath !== $file ) {
status_header( 403 );
exit;
}
// confirm valid real path and file
if ( ( FALSE === $fileRealPath ) || !is_file( $file ) ) {
status_header( 404 );
exit;
}
// determine MIME type
$mime = wp_check_filetype( $file );
if ( FALSE === $mime[ 'type' ] && function_exists( 'mime_content_type' ) ) {
$mime[ 'type' ] = mime_content_type( $file );
}
if ( $mime[ 'type' ] ) {
$mimeType = $mime[ 'type' ];
} else {
// if undetermined assume an image and set MIME type based on file extension (similar to WP core)
$mimeType = 'image/' . substr( $file, strrpos( $file, '.' ) + 1 );
// handle jpg special case
if ( $mimeType = 'image/jpg' ) {
$mimeType = 'image/jpeg';
}
}
// set headers
header( 'Content-Type: ' . $mimeType );
if ( FALSE === strpos( $_SERVER['SERVER_SOFTWARE'], 'Microsoft-IIS' ) ) {
header( 'Content-Length: ' . filesize( $file ) );
}
// send the file
readfile( $file );
flush();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment