Drupal: Securing Private Files with hook_file_download

Drupal: Securing Private Files with hook_file_download

Fix critical security vulnerability in media entities private files

⚠️ 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

  1. Create a protected node with a private file in a media entity
  2. Note the file URL: /system/files/private/document.pdf
  3. Log out or open incognito window
  4. Try accessing the file URL directly
  5. 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

IssueCauseSolution
Files still accessibleCache not cleareddrush cr
Hook not firingModule not enabledEnable module: drush en your_module
Too restrictiveReturn -1 for all filesReturn NULL to defer to other modules
Performance issuesComplex checks on every requestImplement 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
Contact a Drupal security expert

Additional Resources

Article published on 2025-12-06. Complete guide to securing Drupal private files with hook_file_download implementation.

🌱Eco-designed site