Reglas de seguridad en Firebase Storage

  • Por
Cómo construir las reglas de seguridad de Firebase Storage, manteniendo seguros los archivos en tu espacio de almacenamiento.

En el Manual de Firebase ha tocado el turno de hablar de las reglas de seguridad del servicio de almacenamiento. Firebase Storage permite realizar almacenamiento de archivos en la nube, con código completamente del lado del cliente, lo que es asombroso. Sin embargo, el código Javascript que se ejecuta en el navegador nunca se puede dar por seguro, así que es importante realizar validaciones y autorizaciones del lado del servidor.

Cuando trabajamos con Firebase no desarrollamos del lado del servidor, de modo que necesitamos escribir unas reglas de seguridad para permitirnos realizar las debidas comprobaciones, que autoricen las lecturas y escrituras en el espacio de almacenamiento en sus debidos momentos.

Firebase tiene una buena ayuda en castellano en su documentación, pero la verdad es que parece que el lenguaje lo han sacado directamente del traductor automático de Google y revisado muy por encima. Casi es preferible leerlo en inglés. No obstante, el texto es suficientemente extenso y este artículo pretende ser un complemento más que una guía exhaustiva.

Nota: Puedes conocer más del sistema de almacenamiento de Firebase en este manual, en el artículo Introducción a Firebase Storage y también en Control de proceso de Upload de Storage.

Lo básico de las reglas de seguridad de Storage

Las reglas de seguridad son un lenguaje declarativo, en el que especificamos qué debe ocurrir para que se permita o no cierta operación sobre el espacio de almacenamiento. Parece Javascript por su sintaxis, pero no es un JSON como las reglas de la base de datos.

Ten en cuenta esta serie de puntos iniciales:

  • Las reglas de seguridad del Storage se componen mediante rutas a tu espacio de almacenamiento, en las que puedes decir cuándo se permitirían las lecturas y escrituras, para cada ruta.
  • Cuando se realiza una comprobación de un acceso (lectura o escritura) a un ruta, si no hay una regla que concuerde con esa ruta en concreto, no se permite la acción.
  • Podría haber varias reglas definidas sobre una misma ruta. En ese caso, solamente con que una de ellas permita realizar esa acción, será suficiente para que Firebase lo permita.
  • A diferencia de las Reglas de seguridad de la base de datos, en las reglas de seguridad del Storage no se aplica la regla de la cascada. Es decir, permitir acceso a una carpeta no implica que todas las carpetas hijas tendrán acceso.

Comienza por echar un vistazo, en la consola, en la parte de Storage o Almacenamiento, la pestaña "Rules" o "Reglas". Verás un código parecido a este:

service firebase.storage {
  match /b/tu-app-firebase.appspot.com/o {
    match /{allPaths=**} {
      allow read, write: if request.auth != null;
    }
  }
}

Esas reglas indican que se podrá realizar tanto lecturas como escrituras en cualquier espacio de almacenamiento, pero solamente para usuarios debidamente autenticados en tu aplicación.

Luego explicaremos con detalle esas reglas y expresiones. De momento quiero que te quedes con la "ceremonia" de apertura y cierre de tus reglas de seguridad, que sería esta parte del código:

service firebase.storage {
  match /b/tu-app-firebase.appspot.com/o {

  }
}

Entre las llaves colocarás todas las reglas que necesites para tu aplicación. En adelante, al escribir reglas, nos ahorraremos colocar esta "ceremonia" de apertura y cierre, que siempre es igual y depende del nombre de cada app Firebase.

Rutas de almacenamiento

Ya que hemos visto que las rutas son uno de los principales ítem para definir la seguridad del storage, vamos a ver de qué maneras las podríamos definir.

Rutas a ficheros específicos únicos

Podrías definir rutas que solamente casen con un fichero de tu espacio de almacenamiento.

match /test.pdf {
  allow read, write;
}

Esa ruta solo afecta al archivo "test.pdf" colocado en la raíz de tu espacio.

match carpeta/test.txt {
  allow read, write;
}

Esta ruta solo concuerda con el archivo "test.txt" colocado en el directorio "carpeta".

Rutas con comodines de un nivel

Igual que cuando trabajas con el sistema de archivos de tu sistema operativo puedes usar el asterisco "*" para referirte a todos los archivos dentro de una carpeta, puedes usar ese tipo de comodines al escribir rutas. Los llamaré comodines de un nivel.

En las reglas de seguridad del almacenamiento los comodines de un nivel se colocan entre llaves, indicando dentro el nombre de una variable.

match /fotos/{comodin} {
  allow read, write;
}

En este caso estamos refiriéndonos a todos los archivos que haya en la carpeta fotos, por ejemplo: "fotos/img1.jpg" o "fotos/archivo.html". Pero no casaría con "fotos/x/y.jpg", dado que ahí tendríamos dos niveles de carpetas y este comodín sirve para un único nivel de directorio.

La gracia de este tipo de variables "comodín" es que las puedes usar dentro de las llaves del "match" para componer las reglas. En nuestro ejemplo, el nombre del archivo que se pretende leer o escribir dentro de la carpeta "fotos" se tendrá en la variable "comodin".

Rutas con comodines en múltiples niveles

Este otro tipo de comodines afectan no solo al nivel de una carpeta, sino a todos los niveles que puedan existir de anidación de directorios.

match /{allPaths=**} {
  allow read, write;
}

Tal cual está expresado en la ruta anterior, afectará a todos los archivos del espacio de almacenamiento, porque está colocado directamente a continuación de la raíz.

Nota: En este caso también tenemos el comodín (nombre de una variable sin el sufijo "=**" para usarla dentro de las expresiones, pero se trata de un "path object" y la verdad ni en la referencia ni en la documentación especifican mucho lo que se puede hacer, salvo por el siguiente ejemplo que la verdad no llego a encontrarle mucha utilidad.
match /{allPaths=**} {
        allow read: if allPaths == path('/test/imagen.jpg')
        allow write;
    }

Rutas anidadas

La última opción a la hora de componer rutas es anidar unas a otras. Es bastante interesante porque nos permite ser específicos en cada segmento de una ruta, aplicando diversas reglas con una sintaxis bastante reducida.

match /fotos {
  match /test.pdf {
    allow read, write;
  }
  match /{otro} {
    allow read;
  }
}

Comenzando las rutas por "/fotos", tenemos dos rutas anidadas. La primera indica que podremos leer y escribir el archivo "/fotos/test.pdf". La segunda indica que cualquier otro archivo en la carpeta /fotos se podrá leer. Sin embargo como "{otro}" es un comodín de un nivel, si tenemos otros subdirectorios anidados, no se podrán leer. Por ejemplo "/fotos/carpeta/pagina.html" no se podría leer, en cuanto "/fotos/pagina.html" sí se podría.

Expresiones de autorización

Dentro de una ruta podemos especificar diversas expresiones de autorización. Estas se distinguen entre las dos operaciones que se pueden realizar sobre el almacenamiento, tanto lecturas como escrituras.

Las expresiones comienzan siempre con "allow" y luego los nombres de operaciones a las que se pretende autorizar o no (read o write). A continuación y de manera opcional se colocará una expresión boleana que nos diga si tales operaciones se pueden autorizar. Por ejemplo:

allow read;

En esa expresión estamos otorgando permiso de lectura. Si no se indica una condición, el permiso es siempre otorgado

allow read, write;

Así estamos otorgando tanto permisos de lectura como de escritura.

Ahora veamos algunas autorizaciones con su expresión boleana:

allow read, write: if true;

En ese caso también estamos autorizando siempre, puesto que "if true" es una expresión que siempre va a dar positivo.

Obviamente las expresiones pueden tener mucho más sentido cuando usamos otras herramientas del lenguaje como a continuación.

allow write: if request.auth != null;

Esa expresión dará permisos de escritura a usuarios que se hayan autenticado correctamente.

Objetos disponibles para realizar expresiones

Las expresiones pueden usar algunos objetos entregados por el propio sistema de las reglas de seguridad. Aquí las cosas se comienzan a complicar y es bueno acudir a la documentación para conocer todas las posibles alternativas, que son bastante amplias.

Básicamente los objetos que tenemos disponibles son dos:

Objeto request

Nos da información sobre la solicitud, como el usuario que la ha realizado, la ruta a la que se está accediendo, metadata del archivo que se está subiendo o el instante en el tiempo en la que se hace la solicitud.

Objeto resource

Nos ofrece información del archivo que se está subiendo, leyendo o borrando. Son metadatos de lo más variado como su tamaño, instante de creación y actualización, el tipo mime del archivo (contentType), y metadatos personalizados que puedes colocar a tus archivos para guardar datos totalmente arbitrarios que tu aplicación necesite.

Es muy importante por ejemplo que en tus expresiones compruebes el tipo de contenido que se intenta subir. Por ejemplo, si lo que quieres permitir leer en una carpeta son imágenes "jpg", podrías expresarlo así:

allow read: if resource.contentType == 'image/jpg';

O por ejemplo, si lo que quieres escribir es una imagen, con cualquier extensión, podrías usar esta otra alternativa:

allow write: if request.resource.contentType.matches('image/.*')

Autorizaciones ligadas al usuario autenticado

Lo más interesante y a la vez sencillo de entender son las autorizaciones que dependen del usuario que está autenticado.

El objeto request tiene la propiedad "auth" que nos entrega información del usuario autenticado. O bien, si no hay usuario, request.auth valdrá "null". Sabiendo esto, es inmediato entender las reglas predeterminadas que se vieron al principio del artículo.

allow read, write: if request.auth != null;

Eso permite escrituras y lecturas cuando request.auth sea distinto de null. Para que sea distinto de null simplemente necesitamos un usuario autenticado en el sistema, mediante cualquier mecanismo de los disponibles en Firebase.

Podemos también controlar el "uid" (identificador único del usuario), para hacer carpetas en las que un usuario dado tenga acceso de lectura y/o escritura, pero no lo tenga cualquier otro usuario de la aplicación. Para ello nos valdremos de las rutas expresadas con comodines de un nivel:

match /avatar/{userId}/avatar.png {
  allow read;
  allow write: if request.auth.uid == userId;
}

En esta regla, todas las personas (autenticadas o no) podrán leer el archivo "avatar.png" que haya en una ruta que comience por la carpeta "avatar" y luego el subdirectorio que sea. Valdrían rutas como "/avatar/usuario1/avatar.png" o "/avatar/usuario2/avatar.png", pero no algo como "/avatar/avatar.png" o "/avatar/usuariox/otra_carpeta/avatar.png".

En el caso de las escrituras, sólo se podrán escribir archivos que pertenezcan al propio usuario, gracias a la regla:

allow write: if request.auth.uid == userId;

En este caso "userId" es el nombre de la carpeta del avatar que se quiere escribir. Por otra parte request.auth.uid" es el identificador del usuario autenticado (si es que hay uno). En la expresión se comprueba que el identificador del usuario autenticado sea igual a la carpeta donde está el avatar a escribir.

Reglas de seguridad complejas para el storage

Obviamente, cuanto más detallistas seamos en las reglas de seguridad, mejor se garantizará la integridad de la información y la autorización de los accesos. No debemos conformarnos con verificar que se intenta escribir en una posición en la que se tiene permiso, sino que debemos de comprobar si aquello que se intenta escribir o leer tiene la forma que nosotros queremos.

En este último ejemplo tomamos un poco más de detalle en una regla parecida a la anterior del avatar. Ahora vamos a permitir que el avatar tenga cualquier nombre (antes solo era posible "avatar.png").

match /images/avatar {  
  match /{userId}/{allPaths=**} {
    allow read;
    allow write: if request.auth.uid == userId
                  && request.resource.size < 1 * 1024 * 1024
                  && request.resource.contentType.matches('image/.*');
    allow write: if request.auth.uid == userId
                  && request.resource == null;
  }
}

Todo el mundo podrá leer los avatar, pero para escribir necesitaremos que el usuario sea el que realmente le pertenece el avatar, igual que antes.

Además, cuando intentamos escribir, comprobaremos que el avatar tiene un tamaño inferior a 1MB y que el tipo mime sea el de una imagen.

La otra alternativa que permite la escritura es que el archivo que se intenta escribir (request.resource) sea "null", q equivale a un borrado de ese archivo. Si no ponemos esa parte podremos escribir en la carpeta nuevos avatares, pero nuestro usuario no podrá borrar los antiguos.

Conclusión

Con lo que hemos visto estoy seguro que tienes una buena base para poder comenzar con las reglas de seguridad del servicio de storage de Firebase. De todos modos, hay mucho más a continuación de aquí, de lo que podrás echar mano para crear reglas todavía más precisas y completas.

Aparte de la guía de las reglas de seguridad en la documentación de Firebase, te conviene leer también la referencia, donde encontrarás otros ejemplos interesantes y muchas otras alternativas nuevas para asegurar la integridad de tu espacio de almacenamiento.

Autor

Miguel Angel Alvarez

Miguel es fundador de DesarrolloWeb.com y la plataforma de formación online EscuelaIT. Comenzó en el mundo del desarrollo web en el año 1997, transformando su hobby en su trabajo.

Compartir