Recently, I came across an issue where private files in media entities, that were embedded in a paragraph, were accessible by anonymous users. While a user could not get access to the page, access was allowed via direct URL to the file.
In Drupal 8/9, when a file is attached to an entity, it receives some assumptions regarding its general access. That access generally follows the permission access rules of the entity that it's attached to. While this has its benefits, and certainly made sense back in the days when files were directly attached to nodes, it can create a situation where a private file can be directly accessed because its parent entity type is publicly accessible.
In my case, a paragraph type that allowed for embedding different media entities was set to be viewable by all. Even though the parent node of the paragraph was set to a private access control, the extra embed levels of paragraphs and media items created an access override.
After trying out many different setups and modules that added more permission levels and controls, I finally came across a simple hook based fix, for making sure that any file that comes from the private file directory gets it's access checked before downloading. That hook is hook_file_download. I prefer simple code fixes and setups, rather than adding more modules and config to try and manage.
The hook
This hook allows modules to enforce permissions on file downloads whenever Drupal is handling file download, as opposed to the web server bypassing Drupal and returning the file from a public directory.
Basically, what this means is that as long as a file is not being publicly served up through the public file directory, we can use this as an extra check point to make sure that private items stay private. More on this hook here.
The code
I'm including 2 ways to setup some basic control. Hopefully one will help out or guide you to the solution you need.
The first way (# 1) just assumes that all anonymous users should not be able to view any files from the private stream wrapper. The second way (# 2) checks if the current user has a specific permission set. For our case, the custom permission is called `access private files`. If the current user does not have this custom permission, the file is blocked.
use Drupal\Core\StreamWrapper\StreamWrapperManager;
/**
* Implements hook_file_download().
*/
function MODULE_file_download($uri) {
# Check if the file is coming from the private stream wrapper
if (StreamWrapperManager::getScheme($uri) == 'private') {
# 1: Block anonymous users.
if (Drupal::currentUser()->isAnonymous()) {
return -1;
}
# 2: The user does not have the permission "access private files".
if (!\Drupal::currentUser()->hasPermission('access private files')) {
return -1;
}
}
return NULL;
}
For the custom permission, whichever module you place your hook in, you need to add a permission YML file (MYMODULE.permissions.yml) in the module directory, with the following snippet:
access private files:
title: 'Access private files'
description: 'View privately stored files from their direct URL path'
Once the permission is in place, you can assign what roles can access private files as an ultimate access control.
The happy ending
The use case for this setup may be unique for most Drupal sites, but I know I have come across this particular situation on more than one occasion. Node types that leverage the power of paragraphs and media entities, for creating unique page experiences, can easily find themselves in this "Private file access overridden" situation. So make sure to always test private file access by visiting the direct URL access to those files. You never want to be caught exposing your private files on the internet!
For more information about this issue, please see https://www.drupal.org/project/drupal/issues/2984093
and https://www.drupal.org/node/2904842.