⚠️ Critical Security Issue
On Drupal 8/9/10/11, private files in media entities can be directly accessible to anonymous users, even if the parent node is protected. This is a critical security vulnerability!
The Problem: Access Control Bypass
Drupal's default file access control has a significant flaw when dealing with nested entities:
Vulnerable Structure
Node (access controlled)
└─ Paragraph
└─ Media Entity
└─ File (private://) ← DIRECTLY ACCESSIBLE! 🚨Why This Happens
- Access check on immediate parent only: Drupal checks access to the paragraph/media entity, not the ultimate parent node
- Media entities often have permissive access: By default, media entities may grant access to authenticated users
- Direct file URL access: Files can be accessed via
/system/files/endpoint - No recursive permission check: Drupal doesn't traverse up the entity reference chain
Real-World Impact
Scenario: Financial institution with confidential documents
- Private node with role-based access (e.g., "Premium Members Only")
- Contains PDF documents stored in
private:// - Documents embedded via Media entity in a Paragraph
- Result: Anyone with the file URL can download the PDF, bypassing all node-level permissions
The Solution: hook_file_download
The hook_file_download() hook allows you to intercept file download requests and implement custom access control logic.
Approach 1: Block Anonymous Users
Simplest solution: deny all private file access to anonymous users.
Implementation
<?php
// In your_module.module
use Drupal\Core\Session\AccountInterface;
/**
* Implements hook_file_download().
*/
function your_module_file_download($uri) {
// Only apply to private files
if (strpos($uri, 'private://') !== 0) {
return NULL;
}
$current_user = \Drupal::currentUser();
// Block anonymous users from accessing any private files
if ($current_user->isAnonymous()) {
\Drupal::logger('your_module')->warning(
'Anonymous user attempted to access private file: @uri',
['@uri' => $uri]
);
return -1; // Deny access
}
// Allow authenticated users
return NULL; // Let other modules decide
}Approach 2: Role-Based Permissions
More granular control: check specific permissions for private files.
Implementation with Custom Permission
<?php
// In your_module.module
/**
* Implements hook_file_download().
*/
function your_module_file_download($uri) {
if (strpos($uri, 'private://') !== 0) {
return NULL;
}
$current_user = \Drupal::currentUser();
// Check custom permission
if (!$current_user->hasPermission('access private files')) {
\Drupal::logger('your_module')->warning(
'User @uid without permission attempted to access: @uri',
['@uid' => $current_user->id(), '@uri' => $uri]
);
return -1; // Deny access
}
return NULL; // Allow if permission exists
}
/**
* Define the custom permission in your_module.permissions.yml:
*
* access private files:
* title: 'Access private files'
* description: 'Allow user to download private files'
* restrict access: TRUE
*/Approach 3: Check Parent Node Access
Most comprehensive: verify access to the parent node that references the file.
Implementation with Entity Reference Check
<?php
// In your_module.module
use Drupal\file\Entity\File;
use Drupal\media\Entity\Media;
/**
* Implements hook_file_download().
*/
function your_module_file_download($uri) {
if (strpos($uri, 'private://') !== 0) {
return NULL;
}
// Load the file entity
$files = \Drupal::entityTypeManager()
->getStorage('file')
->loadByProperties(['uri' => $uri]);
if (empty($files)) {
return NULL;
}
$file = reset($files);
$current_user = \Drupal::currentUser();
// Find parent media entity
$media_usage = \Drupal::service('file.usage')->listUsage($file);
foreach ($media_usage as $module => $module_usage) {
foreach ($module_usage as $type => $type_usage) {
if ($type === 'media') {
foreach ($type_usage as $media_id => $count) {
$media = Media::load($media_id);
if ($media) {
// Find nodes referencing this media
$nodes = _your_module_find_referencing_nodes($media);
foreach ($nodes as $node) {
// Check node access
if (!$node->access('view', $current_user)) {
\Drupal::logger('your_module')->warning(
'User @uid denied access to file @uri (protected by node @nid)',
[
'@uid' => $current_user->id(),
'@uri' => $uri,
'@nid' => $node->id()
]
);
return -1; // Deny if ANY parent node denies access
}
}
}
}
}
}
}
return NULL; // Allow if no restrictions found
}
/**
* Helper function to find nodes referencing a media entity.
*/
function _your_module_find_referencing_nodes($media) {
$nodes = [];
// Query entity reference fields
$query = \Drupal::entityQuery('node')
->accessCheck(FALSE);
// Add conditions for fields that might reference media
// This depends on your content structure
$group = $query->orConditionGroup();
$group->condition('field_media', $media->id());
$group->condition('field_media_image', $media->id());
// Add more fields as needed
$query->condition($group);
$nids = $query->execute();
if (!empty($nids)) {
$nodes = \Drupal::entityTypeManager()
->getStorage('node')
->loadMultiple($nids);
}
return $nodes;
}Approach 4: MIME Type Filtering
Restrict access based on file type (e.g., only PDFs require authentication).
Implementation with MIME Type Check
<?php
/**
* Implements hook_file_download().
*/
function your_module_file_download($uri) {
if (strpos($uri, 'private://') !== 0) {
return NULL;
}
// Load file entity
$files = \Drupal::entityTypeManager()
->getStorage('file')
->loadByProperties(['uri' => $uri]);
if (empty($files)) {
return NULL;
}
$file = reset($files);
$mime_type = $file->getMimeType();
// Define protected MIME types
$protected_types = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];
if (in_array($mime_type, $protected_types)) {
$current_user = \Drupal::currentUser();
if ($current_user->isAnonymous()) {
\Drupal::logger('your_module')->warning(
'Anonymous user attempted to access protected document: @uri (@mime)',
['@uri' => $uri, '@mime' => $mime_type]
);
return -1; // Deny access
}
}
return NULL; // Allow for other types
}Testing Your Implementation
Manual Testing
- Create a protected node with a private file in a media entity
- Note the file URL:
/system/files/private/document.pdf - Log out or open incognito window
- Try accessing the file URL directly
- Expected result: Access denied (403) or redirect to login
Automated Testing with Playwright
Test Example
import { test, expect } from '@playwright/test';
test('Private files should not be accessible to anonymous users', async ({ page }) => {
// Navigate to a known private file URL
const privateFileUrl = 'https://example.com/system/files/private/confidential.pdf';
const response = await page.goto(privateFileUrl);
// Should be 403 Forbidden or redirect to login (302/303)
expect([403, 302, 303]).toContain(response.status());
});
test('Private files should be accessible to authenticated users', async ({ page }) => {
// Login
await page.goto('/user/login');
await page.fill('#edit-name', 'testuser');
await page.fill('#edit-pass', 'password');
await page.click('#edit-submit');
// Access private file
const response = await page.goto('/system/files/private/confidential.pdf');
// Should be 200 OK
expect(response.status()).toBe(200);
});Best Practices
Security Checklist
- ✅ Always use private:// for sensitive files, never public://
- ✅ Test anonymous access to all private files after deployment
- ✅ Log access attempts to monitor potential security breaches
- ✅ Implement automated tests to prevent regressions
- ✅ Regular security audits of file permissions
- ✅ Use return -1 to deny access, NULL to defer to other modules
- ✅ Consider performance: cache access decisions when possible
Configuration
Verify Private File Path in settings.php
// sites/default/settings.php $settings['file_private_path'] = '../private'; // Ensure the directory exists and is writable // chmod 755 ../private // chown www-data:www-data ../private
Performance Optimization
Cache Access Decisions
<?php
/**
* Implements hook_file_download() with caching.
*/
function your_module_file_download($uri) {
if (strpos($uri, 'private://') !== 0) {
return NULL;
}
$cache_key = 'file_access:' . md5($uri . \Drupal::currentUser()->id());
$cache = \Drupal::cache()->get($cache_key);
if ($cache !== FALSE) {
return $cache->data;
}
// Perform access check (expensive operation)
$access_result = _your_module_check_file_access($uri);
// Cache for 1 hour
\Drupal::cache()->set(
$cache_key,
$access_result,
time() + 3600,
['file_access']
);
return $access_result;
}Troubleshooting
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| Files still accessible | Cache not cleared | drush cr |
| Hook not firing | Module not enabled | Enable module: drush en your_module |
| Too restrictive | Return -1 for all files | Return NULL to defer to other modules |
| Performance issues | Complex checks on every request | Implement caching |
Additional Security Measures
- HTTP headers: Set X-Content-Type-Options: nosniff
- Rate limiting: Prevent brute-force file URL guessing
- Monitoring: Alert on repeated 403 responses
- File encryption: Encrypt sensitive files at rest
- Watermarking: Add user-specific watermarks to PDFs
Need Drupal Security Consulting?
Our VOID team specializes in Drupal security audits and hardening. We can help you:
- Security audits to identify vulnerabilities
- Custom access control implementation
- Penetration testing and remediation
- Compliance (GDPR, HIPAA, PCI-DSS)
- Security training for your development team
Additional Resources
- Drupal File API Documentation
- Drupal Security Best Practices
- Drupal Expertise: our Drupal services
- All our publications: tech guides and news
Article published on 2025-12-06. Complete guide to securing Drupal private files with hook_file_download implementation.