CHALLENGE: we would like to secure uploaded files, so Media Library will be accessible only to users that are logged in
SOLUTION: use htaccess to redirect requests to wp-content/uploads/ files to PHP script.
Extranet systems are used for file sharing, collaboration, accessing confidential documents or displaying company calendar. We can set up Extranet using WordPress CMS, users will have access to data only after logging in. By default WordPress stores all images/documents in the wp-content/uploads/ directory which is publicly accessed. This means if somebody shares a direct file link on the internet, everybody can access it. To protect files – we’re going to use the ‘Orbisius WP Media Protector’ plugin.
Creating mu-plugin
Mu-plugin is a built-in functionality of WordPress that forces a script to be always activated (you cannot deactivate the mu-plugin, the only way to disable it is to remove the file). Here is a simple mu-plugin that will be used to “protect WordPress uploads” – the file will be placed in: /mu-plugins/ct-protect-files.php
<?php if( ! defined( 'ABSPATH' ) ) { die(); } /** * /mu-plugins/ct-protect-files.php * Plugin Name: Protect WP Uploads from unrestricted access * Version: 1.0.2 **/
Allow access for users that are logged in
It’s crucial to prevent unrestricted downloads of extranet files. Here is the ‘orbisius_media_protector’ class written by Svetoslav Marinov. The plugin uses the htaccess rule to force every /wp-content/uploads/ request to be processed by PHP. On the server’s side, we allow file downloading, but only for Administrators and users that are logged in. We’ve added minor modifications to the script:
- removed stripping tags from req_file param
- added a new method – ‘user_has_rights_to_file’ – where we can define custom logic for accessing documents
- use the mod_rewrite_rules filter to automatically add the htaccess rule
Just add the code into: ct-protect-files.php :
/** * Attachment access only for logged in users */ $prot_obj = new ct_orbisius_wp_media_uploads_protector(); add_action('init', [$prot_obj, 'protect_uploads'], 0); /** * @author Svetoslav Marinov (SLAVI) | http://orbisius.com */ class ct_orbisius_wp_media_uploads_protector { function protect_uploads() { if (!empty($_REQUEST['orbisius_media_protector'])) { $req_file = $_REQUEST['orbisius_media_protector']; if (!$this->check_file($req_file)) { wp_die("Invalid request.", 'Error'); } if (headers_sent()) { wp_die("Cannot deliver the file. Headers have already been sent.", 'Error'); } if (is_user_logged_in()) { $user_has_rights = $this->user_has_rights_to_file(); if (!$user_has_rights) { wp_die("You don't have rights to access this file", 'Error'); } // Don't cache the file because the user may log out and try to access it again. // http://stackoverflow.com/questions/13640109/how-to-prevent-browser-cache-for-php-site header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); header("Cache-Control: post-check=0, pre-check=0", false); header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); header("Pragma: no-cache"); header("Expires: Sun, 19 Apr 1981 06:00:00 GMT"); header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT"); header("Connection: close"); // Let's use this just in case. nocache_headers(); $file = ABSPATH . $req_file; if (file_exists($file)) { $content_type = 'application/octet-stream'; $type_rec = wp_check_filetype($file); if (!empty($type_rec['type'])) { $content_type = $type_rec['type']; } // It seems fpassthru sends the correct headers or the // browsers are pretty smart to detect it. header("Content-type: $content_type"); // Offer documents for download if (preg_match('#\.(txt|rtf|pages|pdf|docx?|xlsx?|pptx?)$#si', $file)) { header(sprintf('Content-Disposition: attachment; filename="%s"', basename($file))); // The user needs to know how big the file is. $size = filesize($file); header("Content-length: $size"); } $fp = fopen($file, 'rb'); if (!empty($fp)) { flock($fp, LOCK_SH); fpassthru($fp); flock($fp, LOCK_UN); fclose($fp); } else { status_header(404); echo "Cannot open file."; } } else { status_header(404); global $wp_query; $wp_query->set_404(); echo "File not found."; } } else { $loc = wp_login_url(); $loc = add_query_arg('redirect_to', $req_file, $loc); wp_safe_redirect($loc); } // we either have served the file or have sent the user to the login page. exit; } } /** * Very strict checks for file. No encoded stuff. * Alpha numeric with an extension. * @param str $req_file * @return bool */ function check_file($req_file) { $ok = 0; if ((strpos($req_file, '..') === false) && (strpos($req_file, '/wp-content/uploads/') !== false) ) { $ok = 1; } return $ok; } function user_has_rights_to_file() { if (current_user_can('administrator')) { return true; } if (is_user_logged_in()) { return true; } return false; } } function ct_orbisius_htaccess_contents($rules) { $orbisius_rules = "\n#Orbisius WP Media Protector redirect\n"; $orbisius_rules .= "<IfModule mod_rewrite.c>\n"; $orbisius_rules .= "RewriteEngine On\n"; $orbisius_rules .= "RewriteCond %{REQUEST_URI} ^(.*?/?)wp-content/uploads/.* [NC]\n"; $orbisius_rules .= "RewriteCond %{REQUEST_URI} !orbisius_media_protector [NC]\n"; $orbisius_rules .= "RewriteRule . %1/?orbisius_media_protector=%{REQUEST_URI} [L,QSA]\n"; $orbisius_rules .= "</IfModule>\n"; $orbisius_rules .= "#End Orbisius WP Media Protector redirect\n"; return $orbisius_rules . $rules; } add_filter('mod_rewrite_rules', 'ct_orbisius_htaccess_contents');
Disable WordPress attachment pages
For every Media Library item (uploaded image or document) – WordPress creates a new “post” in the database which has a custom url publicly available. Attachments are stored in a wp_posts table as “attachment” post_type. We already have access to secured files, but attachment pages can include a filename which can also contain private information. By default, these pages can’t be disabled and are often indexed by the Google Search Engine. The solution will be to use the ‘Disable Attachment Pages’ plugin written by Greg Schoppe. PHP class GJSDisableAttachmentPages disables attachments the right way. Files’ slugs will remain available for other posts and pages. We’re going to add code directly to our mu-plugin.
/** * Disable attachments pages */ /* * Author: Greg Schoppe * Author URI: https://gschoppe.com/ */ if( ! class_exists( 'GJSDisableAttachmentPages' ) ) { class GJSDisableAttachmentPages { public static function Instance() { static $instance = null; if ($instance === null) { $instance = new self(); } return $instance; } private function __construct() { $this->init(); register_activation_hook( __FILE__, 'flush_rewrite_rules' ); register_deactivation_hook( __FILE__, 'flush_rewrite_rules' ); } public function init() { add_filter( 'rewrite_rules_array', array( $this, 'remove_attachment_rewrites' ) ); add_filter( 'wp_unique_post_slug', array( $this, 'wp_unique_post_slug' ), 10, 6 ); add_filter( 'request', array( $this, 'remove_attachment_query_var' ) ); add_filter( 'attachment_link' , array( $this, 'change_attachment_link_to_file' ), 10, 2 ); // just in case everything else fails, and somehow an attachment page is requested add_action( 'template_redirect', array( $this, 'redirect_attachment_pages_to_file' ) ); // this does nothing currently, but maybe someday will, if WordPress standardizes attachments as a post type add_filter('register_post_type_args', array( $this, 'make_attachments_private' ), 10, 2); } public function remove_attachment_rewrites( $rules ) { foreach ( $rules as $pattern => $rewrite ) { if ( preg_match( '/([\?&]attachment=\$matches\[)/', $rewrite ) ) { unset( $rules[$pattern] ); } } return $rules; } // this function is a trimmed down version of `wp_unique_post_slug` from WordPress 4.8.3 public function wp_unique_post_slug( $slug, $post_ID, $post_status, $post_type, $post_parent, $original_slug ) { global $wpdb, $wp_rewrite; if ( $post_type =='nav_menu_item' ) { return $slug; } if ( $post_type == "attachment" ) { $prefix = apply_filters( 'gjs_attachment_slug_prefix', 'wp-attachment-', $original_slug, $post_ID, $post_status, $post_type, $post_parent ); if ( ! $prefix ) { return $slug; } // remove this filter and rerun with the prefix remove_filter( 'wp_unique_post_slug', array( $this, 'wp_unique_post_slug' ), 10 ); $slug = wp_unique_post_slug( $prefix . $original_slug, $post_ID, $post_status, $post_type, $post_parent ); add_filter( 'wp_unique_post_slug', array( $this, 'wp_unique_post_slug' ), 10, 6 ); return $slug; } if ( ! is_post_type_hierarchical( $post_type ) ) { return $slug; } $feeds = $wp_rewrite->feeds; if( ! is_array( $feeds ) ) { $feeds = array(); } /* * NOTE: This is the big change. We are NOT checking attachments along with our post type */ $slug = $original_slug; $check_sql = "SELECT post_name FROM $wpdb->posts WHERE post_name = %s AND post_type IN ( %s ) AND ID != %d AND post_parent = %d LIMIT 1"; $post_name_check = $wpdb->get_var( $wpdb->prepare( $check_sql, $slug, $post_type, $post_ID, $post_parent ) ); /** * Filters whether the post slug would make a bad hierarchical post slug. * * @since 3.1.0 * * @param bool $bad_slug Whether the post slug would be bad in a hierarchical post context. * @param string $slug The post slug. * @param string $post_type Post type. * @param int $post_parent Post parent ID. */ if ( $post_name_check || in_array( $slug, $feeds ) || 'embed' === $slug || preg_match( "@^($wp_rewrite->pagination_base)?\d+$@", $slug ) || apply_filters( 'wp_unique_post_slug_is_bad_hierarchical_slug', false, $slug, $post_type, $post_parent ) ) { $suffix = 2; do { $alt_post_name = _truncate_post_slug( $slug, 200 - ( strlen( $suffix ) + 1 ) ) . "-$suffix"; $post_name_check = $wpdb->get_var( $wpdb->prepare( $check_sql, $alt_post_name, $post_type, $post_ID, $post_parent ) ); $suffix++; } while ( $post_name_check ); $slug = $alt_post_name; } return $slug; } public function remove_attachment_query_var( $vars ) { if ( ! empty( $vars['attachment'] ) ) { $vars['page'] = ''; $vars['name'] = $vars['attachment']; unset( $vars['attachment'] ); } return $vars; } public function make_attachments_private( $args, $slug ) { if ( $slug == 'attachment' ) { $args['public'] = false; $args['publicly_queryable'] = false; } return $args; } public function change_attachment_link_to_file( $url, $id ) { $attachment_url = wp_get_attachment_url( $id ); if ( $attachment_url ) { return $attachment_url; } return $url; } public function redirect_attachment_pages_to_file() { if ( is_attachment() ) { $id = get_the_ID(); $url = wp_get_attachment_url( $id ); if ( $url ) { wp_redirect( $url, 301 ); die; } } } } GJSDisableAttachmentPages::Instance(); }
Testing the solution
Upload protection should be working now. Just try to open some Media Library file in the browser, e.g.: /wp-content/uploads/2021/01/some-file1.pdf . If you’re not logged in as wp_user, you can’t access the file and will be redirected to the wp_login form.
Troubleshooting
Q: I use Sucuri Firewall and secure uploads sometimes do not work (some files are properly blocked/others are not)
A: Sucuri is a solution that is always caching “static files” (even with the “Disabled” option selected in “Cache Level”). In practice: on first request to file: server will run a PHP script and check the ‘user_has_rights_to_file’ method. If the user is logged in, the file will be served to the user. On the second request, Sucuri will not fire the PHP script, but get the result from its cache (serving file directly). Then, if ‘non logged in’ user knows the file path, he can access the file (without the need to be logged in). As you see, Sucuri Firewall configuration is in conflict with “protecting static files using htaccess”. In theory, ‘Cache Exceptions’ or file versioning can be used → https://docs.sucuri.net/website-firewall/performance/cache-exceptions/ . However, these solutions will not suit our needs. My recommendation will be not to use Sucuri Firewall in this situation.
This concludes today’s tutorial. Make sure you follow us for other useful tips and guidelines.
Comments
0 response