Restringir archivos privados en un CMS desacoplado
Cómo una plataforma Drupal 11 desacoplada retiene un archivo privado hasta que un visitante se lo ha ganado, sin filtrar la ruta del archivo, sin acoplar el CMS a un front end concreto y sin confiar al navegador nada que pudiera falsificar, y por qué convertimos el resultado en un módulo de código abierto.
La palabra "privado" carga con demasiado trabajo
Adjunte un archivo a un contenido, marque su almacenamiento como privado, y es natural suponer que el archivo ya está protegido. En un sistema desacoplado esa suposición es discretamente errónea, y la brecha que oculta es la razón por la que existe este documento.
Drupal decide quién puede descargar un archivo privado haciendo una pregunta simple: ¿puede este usuario ver la entidad que referencia el archivo? Para el contenido publicado la respuesta, para los visitantes anónimos, es sí. Así que un archivo privado adjunto a una página publicada puede descargarlo cualquiera que tenga el enlace, lo adivine o lo reciba. El esquema private:// significa "no está en un directorio público", no "se entrega solo a quienes se lo han ganado". Es una ofuscación, no una restricción de acceso.
Para la mayoría de los archivos esa distinción nunca sale a la luz. Para los archivos que una empresa realmente quiere restringir —un informe técnico tras un formulario de captación, un informe tras un inicio de sesión, un entregable tras una compra— lo es todo. Y una arquitectura desacoplada la agudiza aún más: el front end que controla la condición de acceso (el formulario de captación, la sesión, el pago) es un sistema distinto del CMS que controla el archivo. Algo tiene que conectarlos sin entregar al navegador una URL que eluda la restricción por completo.
Principio: la condición de acceso pertenece al consumidor, la concesión pertenece a la plataforma
La primera decisión es una división del trabajo, y acertar en ella es lo que mantiene el sistema desacoplado. Hay dos tareas distintas, y corresponden a dos lugares diferentes.
Decidir si un visitante ha cumplido la condición de acceso es tarea del front end. La condición es un formulario de captación, una sesión autenticada, una compra completada: lógica de negocio que vive donde está el visitante, en la aplicación consumidora. El CMS no tiene opinión sobre cómo funciona esa condición y nunca debería intentar tenerla; en el momento en que la tiene, queda acoplado a ese front end concreto y no puede servir al siguiente.
Decidir si se entregan los bytes es tarea de la plataforma. Una vez que el front end afirma "este visitante está autorizado", algo de confianza tiene que convertir esa afirmación en un permiso real y verificable para descargar un archivo específico durante una ventana breve. Eso es una preocupación transversal —una aplicación nativa, un segundo sitio o una integración necesitarán cada uno la misma capacidad—, así que pertenece al CMS, expuesta de la misma manera a todos los consumidores.
El puente entre ambos es una concesión firmada: el front end aplica su condición de acceso como prefiera y luego pide a la plataforma que emita un permiso de corta duración, firmado criptográficamente, para exactamente un archivo. El navegador canjea ese permiso y obtiene los bytes. Nada en ese intercambio presupone React, Next.js, un cliente nativo ni un tema Twig acoplado, que es precisamente lo que lo hace reutilizable.
Denegar en el archivo, permitir en una ruta propia
El mecanismo se desprende directamente del principio. Si la ruta de descarga predeterminada concede acceso según "puede ver el contenido", entonces no se puede confiar en la ruta de descarga predeterminada para un archivo restringido, así que el primer paso es quitarla de la mesa.
File Gate implementa el hook de descarga de archivos de Drupal para devolver un veto firme —un "no" definitivo, no una abstención cortés— para cualquier archivo privado referenciado por un campo marcado como restringido. Ese veto anula el acceso permisivo del núcleo a los archivos privados. La ruta ordinaria /system/files, la que de otro modo entregaría el archivo a cualquier visitante anónimo, queda cerrada para los archivos restringidos y nunca se reabre ahí. La única excepción es un permiso Bypass file gate, para que los editores de confianza puedan seguir descargando desde la interfaz de administración mientras trabajan.
La entrega ocurre, en cambio, en una ruta que pertenece al módulo. El navegador del visitante canjea una URL firmada en un punto de descarga dedicado; el módulo verifica la concesión y transmite el archivo directamente, como una respuesta binaria marcada con Cache-Control: private, no-store. De esto se derivan dos propiedades que un enfoque de "simplemente hacer que el núcleo lo permita" no puede ofrecer:
- La ruta de permiso es independiente de las reglas de acceso del núcleo. El canje no depende de que el visitante pueda ver la entidad que referencia el archivo, de modo que restringir un archivo nunca requiere relajar nada más, y nunca puede relajarse por accidente cuando se publica el contenido.
- La ruta de almacenamiento nunca se revela. La URL firmada lleva el UUID del archivo, no su ubicación
private://. La ruta que una API desacoplada expondría de otro modo —justo lo que hace fallar "no adivinable"— simplemente nunca aparece en la respuesta.
La concesión es una promesa firmada que el cliente no puede reescribir
Una URL firmada de corta duración solo es segura si ninguna de las cosas que la hacen segura puede ser editada por quien la posee. Así que cada restricción que importa se incorpora a la propia firma.
La concesión es una firma HMAC-SHA256 sobre el identificador normalizado del archivo y un conjunto canónico de reclamaciones: la expiración, un "no antes de" opcional y cualquier token o límite de uso. Cambie el archivo, extienda la expiración o elimine el límite de uso, y la firma deja de coincidir; la descarga se rechaza. La verificación es en tiempo constante (hash_equals()), de modo que la comparación misma no revela nada sobre el valor correcto. El secreto de firma vive en el entorno, nunca en la configuración exportada, y solo se usa del lado del servidor: el navegador ve la firma derivada, nunca la clave que la produjo.
La emisión es deliberadamente un intercambio de servidor a servidor. El back end del front end llama al punto de emisión con el UUID del archivo o del medio y el secreto compartido; la plataforma devuelve una ruta relativa a la raíz, independiente del host y limitada por TTL, para que el navegador la canjee. El secreto nunca llega al navegador. El artefacto público es siempre la firma, nunca la capacidad de producir una.
Fallar en modo cerrado, a propósito
Los valores predeterminados de seguridad son una decisión de diseño, y el valor predeterminado honesto para una restricción de acceso es estar cerrada. Si no hay un secreto de firma configurado, File Gate no recurre a servir archivos "por ahora": se niega. La emisión devuelve un 503, y todos los archivos restringidos permanecen denegados. Una configuración incorrecta, una variable de entorno faltante, un despliegue a medias: cada uno produce una falla visible, no una fuga silenciosa. El modo de falla de "la restricción aún no está conectada" es que nada sale, que es el único modo de falla que una restricción de acceso puede permitirse.
Un campo restringido no puede almacenar un archivo público
La restricción se configura donde corresponde —en el campo— y se aplica en el nivel donde ocurren los errores. Marcar un campo de archivo o imagen como restringido hace dos cosas a la vez: obliga al campo a almacenar en el sistema de archivos privado y bloquea ese control, porque un archivo público, legible por todos, no puede restringirse en primer lugar. No existe configuración en la que un campo esté "restringido" pero escriba discretamente archivos en un directorio público. El ajuste viaja con la configuración exportada como ajuste de terceros en el almacenamiento del campo, de modo que la garantía es reproducible entre entornos en lugar de una casilla manual que alguien tiene que recordar.
La huella de dependencias es deliberadamente pequeña: el único requisito estricto es el sistema de archivos del núcleo. La restricción funciona en un campo de origen de Media, un campo de archivo simple o cualquier otro campo de archivo privado, de modo que un sitio puede adoptarla sin comprometerse con un modelo de contenido concreto.
Exponer los metadatos, no la URL
Un front end desacoplado sigue necesitando mostrar el recurso restringido —su nombre, su tipo, su tamaño, el hecho de que existe— para que el visitante pueda decidir cumplir la condición de acceso para obtenerlo. La regla que mantiene esto seguro es exponer todos esos metadatos a través de la API (JSON:API o GraphQL) mientras se retiene el único campo que anularía la restricción: una URL de archivo directamente resoluble. El front end sabe que existe un documento llamado "Informe de cumplimiento 2026", de 2,4 MB, en PDF; no recibe un enlace que transmita los bytes. Ese enlace solo llega a existir cuando el back end lo emite, tras la condición de acceso, para un visitante, por un tiempo breve.
La costura que lo convierte en algo más que una restricción de archivos
La decisión más valiosa del diseño es una que el usuario nunca ve: el núcleo de firma y emisión no sabe que está tratando con archivos. Opera sobre un identificador de recurso opaco y un conjunto de reclamaciones. Solo tres piezas estrechas son específicas de archivos: el hook de descarga que deniega /system/files, la ruta que transmite los bytes y el control de campo que obliga al almacenamiento privado. Todo lo relativo a demostrar que "esta concesión es válida para este recurso hasta este momento" es independiente del destino.
Esa costura no es decorativa. Significa que el mismo mecanismo verificado que restringe un archivo puede restringir más adelante un tipo de recurso distinto —un contenido completo, una respuesta de API, una entidad— sin volver a discutir la criptografía. Y la cuestión de cómo una solicitud demuestra que cumplió la condición de acceso es a su vez extensible: un pequeño tipo de complemento permite a un sitio añadir un método de acceso en unas pocas líneas. Hoy se incluyen ocho —una URL firmada, entrega a usuarios autenticados, tokens revocables y precompartidos, un bloqueo por referente/origen, un código de un solo uso enviado por correo electrónico, un formulario de captación acoplado, una restricción por compra o derecho de acceso y un nivel de garantía OIDC respaldado por hardware (PIV/CAC o FIDO2/WebAuthn)— y cada uno llegó como un complemento, no como una reescritura. Construimos la solución a medida como si fuera un producto, porque una capacidad que resuelve un problema recurrente entre clientes es un producto encubierto, y la disciplina de construirla como tal es lo que le permite viajar.
Ser honestos sobre el límite de confianza
Todo sistema de seguridad traza una línea y tiene un conjunto de cosas que no promete, y negarse a nombrarlas es la forma en que fallan los sistemas de apariencia sensata. File Gate traza su línea con claridad.
El punto de emisión confía en el llamador que posee el secreto. No vuelve a verificar —no puede— la condición de acceso propia del front end; esa lógica vive en el front end por diseño. La seguridad de todo el conjunto descansa, por tanto, en dos hechos operativos: el secreto sigue siendo secreto, y el punto de emisión permanece en una red de confianza y con límite de tasa contra abusos. Son responsabilidades de despliegue que el módulo hace explícitas en lugar de disimular.
Y los límites de uso —"este enlace puede canjearse una vez"— se aplican con un contador rápido con expiración, no con un bloqueo estricto. En una condición de carrera ajustada, un cliente decidido podría conseguir un canje adicional. Eso es totalmente adecuado para la generación de clientes potenciales y límites informales, y no es un mecanismo de aplicación de licencias. Decirlo con claridad forma parte del diseño: una restricción de acceso que exagera sus garantías es peor que una que las enuncia con exactitud.
Principios que vale la pena conservar
El módulo importa menos que los principios que el trabajo puso de manifiesto, que se aplican a restringir cualquier recurso en cualquier plataforma desacoplada:
- "Privado" no es "restringido". No-estar-en-un-directorio-público y entregarse-solo-tras-una-condición-de-acceso son garantías diferentes. Sepa cuál tiene en realidad.
- Separe la condición de acceso de la concesión. Decidir quién pasa corresponde al consumidor que controla la interacción; emitir un permiso verificable corresponde a la plataforma que controla el recurso. Manténgalos separados y la plataforma seguirá siendo reutilizable.
- Deniegue en el origen, permita en sus propios términos. Si una ruta predeterminada concede acceso por la razón equivocada, ciérrela y entregue a través de una ruta cuyas reglas usted controle, y que no filtre ninguna ruta de almacenamiento.
- Vincule todo a la firma. Cualquier restricción que el cliente pudiera editar de otro modo —el archivo, la expiración, el límite de uso— debe formar parte de lo que se firma, o no es una restricción. Y debe codificarse sin ambigüedad antes de firmarse: si dos conjuntos de reclamaciones distintos pueden serializarse en los mismos bytes, una reclamación vinculada puede eliminarse discretamente mientras la firma sigue verificándose, así que la forma canónica tiene que ser inyectiva.
- Falle en modo cerrado. El valor predeterminado seguro para una restricción de acceso es cerrado. Un secreto faltante debe detener la entrega, de forma visible, no degradarse a servir archivos.
- Nombre el límite de confianza. Declare en qué confía y qué no promete. Una restricción de acceso honesta sobre sus bordes es más fuerte que una que finge no tenerlos.
Restringir un archivo privado suena a una casilla de verificación y resulta ser un problema de arquitectura: dónde vive la condición de acceso, quién puede emitir una concesión, qué puede prometer la concesión y qué ocurre cuando el cableado está incompleto. Responder esas preguntas de forma deliberada produjo una capacidad correcta por construcción, desacoplada de cualquier front end concreto y reutilizable mucho más allá del archivo con el que empezó: el módulo de código abierto File Gate.