Drupal : Fichiers privés et faille d'accès

Drupal : Fichiers privés et faille d'accès

Comment sécuriser les media entities avec hook_file_download

🇫🇷 This article is currently available in French. English translation in progress.

Sur Drupal 8/9/10, il existe une faille de sécurité peu connue mais critique : les fichiers privés attachés à des media entities peuvent être accessibles en direct par des utilisateurs anonymes, même si le contenu parent est protégé. Cette vulnérabilité peut exposer des documents confidentiels sur internet sans que vous le sachiez.

Cet article explique le problème, comment le détecter, et surtout lasolution technique pour le corriger avec hook_file_download.

Le problème : Accès non autorisé aux fichiers privés

Récemment, chez VOID, nous avons découvert qu'un site Drupal exposait des documents privés de clients bancaires. Le scénario était le suivant :

  • Un node (article) est protégé par des permissions d'accès (visible uniquement par les utilisateurs connectés)
  • Ce node contient un paragraph (champ personnalisé)
  • Le paragraph contient une media entity (PDF, image, vidéo)
  • Le fichier de la media entity est stocké dans le répertoire private://

Résultat : même si l'utilisateur anonyme ne peut pas voir le node, il peut accéder directement au fichier via son URL (ex : /system/files/private/2025-01/confidential.pdf).

⚠️ Pourquoi ce problème existe ?

Dans Drupal 8/9/10, quand un fichier est attaché à une entité, il hérite des permissions d'accès de l'entité parent. Cela fonctionnait bien à l'époque où les fichiers étaient directement attachés aux nodes.

Mais : avec l'introduction des paragraphs et media entities, il y a maintenant plusieurs niveaux d'imbrication. L'accès au fichier se base sur l'entité immédiatement parente (le paragraph ou la media), et non sur le node racine.

Exemple concret de la faille

Contexte : Site bancaire avec espace membre. Seuls les clients connectés doivent pouvoir voir leurs documents (relevés bancaires, contrats).

Structure du contenu

  • 📄 Node : "Mon espace client" (accès : utilisateurs connectés uniquement)
  • ↳ 📦 Paragraph : "Documents" (type : paragraph, viewable par tous par défaut)
  • ↳ 🎬 Media entity : "Relevé bancaire" (type : document, viewable par tous)
  • ↳ 📁 Fichier : private://releve_client_2025.pdf

Résultat : un utilisateur anonyme tape directement https://banque.ma/system/files/private/releve_client_2025.pdf Le fichier est téléchargé sans authentification

Comment détecter cette faille ?

Test simple :

  1. Créez un node avec un paragraph contenant une media entity (PDF, image)
  2. Stockez le fichier dans private://
  3. Restreignez l'accès du node (ex : uniquement utilisateurs connectés)
  4. Déconnectez-vous (mode navigation privée)
  5. Copiez l'URL directe du fichier (clic droit sur l'image/lien → copier l'adresse)
  6. Visitez cette URL en mode navigation privée → Si le fichier se télécharge, vous avez la faille

La solution : hook_file_download

Drupal propose un hook spécifique pour contrôler l'accès aux fichiers privés : hook_file_download (documentation officielle).

Ce hook permet de vérifier les permissions avant chaque téléchargement de fichier depuis le répertoire private://. Si l'accès est refusé, le fichier n'est pas servi.

Approche 1 : Bloquer les utilisateurs anonymes

Cas d'usage : Tous les fichiers privés sont réservés aux utilisateurs connectés.

<?php

use Drupal\Core\StreamWrapper\StreamWrapperManager;

/**
 * Implements hook_file_download().
 */
function MYMODULE_file_download($uri) {
  // Vérifier si le fichier vient du stream wrapper 'private://'
  if (StreamWrapperManager::getScheme($uri) == 'private') {
    
    // Si l'utilisateur est anonyme, bloquer l'accès
    if (\Drupal::currentUser()->isAnonymous()) {
      return -1; // Refuser l'accès
    }
  }
  
  return NULL; // Laisser Drupal gérer normalement
}

Approche 2 : Permission personnalisée

Cas d'usage : Contrôle granulaire avec une permission spécifique (ex : seuls les clients premium peuvent télécharger).

<?php

use Drupal\Core\StreamWrapper\StreamWrapperManager;

/**
 * Implements hook_file_download().
 */
function MYMODULE_file_download($uri) {
  // Vérifier si le fichier vient du stream wrapper 'private://'
  if (StreamWrapperManager::getScheme($uri) == 'private') {
    
    // Vérifier si l'utilisateur a la permission "access private files"
    if (!\Drupal::currentUser()->hasPermission('access private files')) {
      return -1; // Refuser l'accès
    }
  }
  
  return NULL; // Laisser Drupal gérer normalement
}

Créer la permission personnalisée

Dans votre module custom, créez le fichier MYMODULE.permissions.yml :

access private files:
  title: 'Access private files'
  description: 'View privately stored files from their direct URL path'
  restrict access: true

Ensuite, dans People → Permissions, assignez cette permission aux rôles autorisés (ex : Client, Premium, Admin).

Approche 3 : Vérification avancée selon le type de fichier

Pour un contrôle encore plus fin, vous pouvez vérifier le type MIME ou le chemin du fichier :

<?php

use Drupal\Core\StreamWrapper\StreamWrapperManager;
use Drupal\file\Entity\File;

/**
 * Implements hook_file_download().
 */
function MYMODULE_file_download($uri) {
  if (StreamWrapperManager::getScheme($uri) == 'private') {
    
    // Charger l'entité File
    $files = \Drupal::entityTypeManager()
      ->getStorage('file')
      ->loadByProperties(['uri' => $uri]);
    
    if (!empty($files)) {
      $file = reset($files);
      $mime_type = $file->getMimeType();
      
      // Bloquer les PDFs pour les utilisateurs anonymes
      if ($mime_type == 'application/pdf' && \Drupal::currentUser()->isAnonymous()) {
        return -1;
      }
      
      // Autoriser les images pour tous
      if (strpos($mime_type, 'image/') === 0) {
        return NULL; // Autoriser
      }
    }
    
    // Par défaut, vérifier la permission
    if (!\Drupal::currentUser()->hasPermission('access private files')) {
      return -1;
    }
  }
  
  return NULL;
}

Implémentation chez VOID

Chez VOID, pour nos projets bancaires et institutionnels, nous utilisons une approche hybride :

  1. Bloquer les anonymes par défaut : isAnonymous() retourne -1
  2. Permission granulaire par rôle : access private files pour les clients
  3. Logging des tentatives d'accès : avec \\Drupal::logger() pour audit
  4. Tests automatisés : Playwright vérifie que les fichiers privés ne sont pas accessibles en mode anonyme
<?php

use Drupal\Core\StreamWrapper\StreamWrapperManager;

/**
 * Implements hook_file_download().
 */
function void_security_file_download($uri) {
  if (StreamWrapperManager::getScheme($uri) == 'private') {
    $current_user = \Drupal::currentUser();
    
    // Bloquer les anonymes
    if ($current_user->isAnonymous()) {
      \Drupal::logger('void_security')->warning('Anonymous user tried to access @uri', [
        '@uri' => $uri,
      ]);
      return -1;
    }
    
    // Vérifier la permission
    if (!$current_user->hasPermission('access private files')) {
      \Drupal::logger('void_security')->warning('User @uid without permission tried to access @uri', [
        '@uid' => $current_user->id(),
        '@uri' => $uri,
      ]);
      return -1;
    }
  }
  
  return NULL;
}

Bonnes pratiques complémentaires

  • Toujours utiliser private:// pour les fichiers sensibles : évitez public:// qui sert les fichiers directement via Apache/Nginx
  • Configurer les media entities : dans Structure → Media types, vérifiez que le champ File utilise bien private://
  • Auditer les permissions des paragraphs : même si le node est protégé, assurez-vous que les paragraphs ne sont pas "viewable par tous" par défaut
  • Tester régulièrement : ajoutez des tests automatisés (Playwright, Behat) qui vérifient l'accès anonyme aux fichiers privés
  • Activer les logs : utilisez \\Drupal::logger() pour tracer les tentatives d'accès refusées
  • Documenter la configuration : expliquez à l'équipe éditoriale comment uploader des fichiers dans private://

Alternatives et modules complémentaires

Si vous préférez une solution sans code custom, voici quelques modules Drupal :

  • Media Entity File Replace : gestion avancée des permissions sur les media entities
  • Field Permissions : contrôle granulaire des permissions sur les champs de fichiers
  • Private Files Download Permission : ajoute une permission spécifique pour les fichiers privés

Recommandation VOID : nous préférons la solution hook_file_download car elle est légère, maintenable, et ne dépend pas de modules tiers.

Tests de non-régression

Exemple de test Playwright pour vérifier que les fichiers privés sont bien protégés :

// tests/private-files-access.spec.ts
import { test, expect } from '@playwright/test';

test('Anonymous users cannot access private files', async ({ page }) => {
  // URL directe d'un fichier privé (exemple)
  const privateFileUrl = 'https://example.com/system/files/private/confidential.pdf';
  
  // Tenter d'accéder en mode anonyme
  const response = await page.goto(privateFileUrl);
  
  // Vérifier que l'accès est refusé (403 ou redirection)
  expect(response?.status()).toBe(403); // ou 302 si redirection vers login
  
  // Vérifier que le contenu n'est pas du PDF
  const contentType = response?.headers()['content-type'];
  expect(contentType).not.toContain('application/pdf');
});

test('Authenticated users can access private files', async ({ page }) => {
  // Login
  await page.goto('https://example.com/user/login');
  await page.fill('#edit-name', 'testuser');
  await page.fill('#edit-pass', 'testpass');
  await page.click('#edit-submit');
  
  // Tenter d'accéder au fichier privé
  const privateFileUrl = 'https://example.com/system/files/private/confidential.pdf';
  const response = await page.goto(privateFileUrl);
  
  // Vérifier que l'accès est autorisé
  expect(response?.status()).toBe(200);
  expect(response?.headers()['content-type']).toContain('application/pdf');
});

Issues Drupal.org à suivre

Cette problématique est documentée sur Drupal.org depuis plusieurs années :

Besoin d'un audit de sécurité Drupal ?

Chez VOID, nous effectuons des audits de sécurité complets sur les sites Drupal : analyse des permissions, vérification des fichiers privés, revue de code, tests de pénétration. Sécurisez votre plateforme avant qu'il ne soit trop tard.

Demander un audit de sécurité

Conclusion

La faille d'accès aux fichiers privés dans Drupal 8/9/10 est un problème de sécurité critique mais facilement corrigeable avec hook_file_download. Ne laissez pas vos documents confidentiels exposés sur internet : testez dès aujourd'hui vos fichiers privés en mode navigation privée, et implémentez cette solution si nécessaire.

Checklist de sécurité :

  • ✅ Tous les fichiers sensibles sont dans private://
  • hook_file_download est implémenté
  • ✅ Les utilisateurs anonymes ne peuvent pas accéder aux fichiers privés (testé manuellement)
  • ✅ Une permission personnalisée contrôle l'accès selon les rôles
  • ✅ Les tentatives d'accès refusées sont loguées
  • ✅ Des tests automatisés (Playwright) vérifient l'accès en continu

Ressources complémentaires

Cet article présente une solution technique pour Drupal 8/9/10. Les exemples de code sont testés et utilisés en production chez VOID sur des projets bancaires et institutionnels. Informations à jour en 2025.