Saltar al contenido
Development

Cuando una app no puede quitar un permiso que ya otorgó

Por Victor Da Luz
taurirustsecuritydev-loggreenhouse

Esta semana encontré un error de permisos en Greenhouse que no tiene un arreglo limpio. No “limpio” en el sentido de “no me esforcé lo suficiente,” limpio en el sentido de que la API subyacente genuinamente no soporta deshacer lo que permite hacer, y el arreglo es una decisión de compromiso de UX, no un truco de código.

El problema

Greenhouse es una app de escritorio (Tauri v2 + Rust + Svelte) donde el usuario elige una carpeta en el disco, su “vault,” y la app administra los proyectos dentro de ella. Para previsualizar archivos de audio en la app, el webview necesita acceso de lectura a esa carpeta. La respuesta de Tauri para “dejar que el webview lea esta carpeta” es el scope en tiempo de ejecución del protocolo de assets: se llama a allow_directory(path, recursive) una vez que se conoce la ruta, y convertFileSrc empieza a funcionar para cualquier cosa dentro de ella.

Greenhouse también permite cambiar de vault sin reiniciar, apuntar la app a una carpeta distinta a mitad de sesión. Debería ser fácil: otorgar scope a la carpeta nueva, listo.

Salvo que me puse a buscar la otra mitad de esa API, algo como forbid_directory o revoke, para que cambiar de vault también quitara el acceso de lectura a la anterior. No existe. Leí el código fuente de Tauri incluido en el proyecto para asegurarme: el scope está respaldado por un allow-set y un forbid-set, y allow_directory solo inserta en el allow-set. Nada quita elementos de ahí, durante toda la vida del proceso.

Por qué este enfoque

El primer instinto fue forbid_directory(old_path) al cambiar, combatir fuego con fuego. Eso tiene su propia trampa: los patrones prohibidos tienen precedencia sobre los permitidos en la verificación is_allowed de Tauri, y tampoco existe un “des-prohibir.” Prohibir el vault A una vez deja el volver a A más adelante en la misma sesión permanentemente roto, habría que reiniciar de todos modos, solo que en un peor momento (cuando el usuario está tratando de volver a un vault en el que ya confía).

Entonces las opciones reales eran: aceptar que cada vault visitado se mantenga legible por el webview durante toda la sesión y documentarlo, otorgar acceso archivo por archivo en vez de carpeta por carpeta (mucha más superficie de IPC para muy poco beneficio en una app local de un solo usuario), o hacer que un cambio de vault surta efecto recién al reiniciar, en vez de en vivo. Elegí reiniciar para cambiar. Un proceso nuevo arranca con cero concesiones de scope, ese es el único momento en que el problema de acumulación no existe en absoluto, porque todavía no se otorgó nada.

Implementación

El comando que establece la raíz del vault ahora captura cuál era la raíz anterior antes de hacer cualquier otra cosa, y la compara con la nueva. Si son la misma (configuración por primera vez, o elegir el vault en el que ya se está), se activa de inmediato como antes. Si difieren, es un cambio real: la ruta nueva se persiste en la configuración para que el próximo arranque la tome, pero la concesión de scope real y la activación en memoria se saltan por completo. El comando devuelve si hace falta un reinicio, y el frontend muestra un panel de “reiniciar para cambiar” en vez de simplemente cerrar el diálogo.

El reinicio en sí es una línea, relaunch() de tauri-plugin-process, protegida por una capability limitada a ese único permiso, no la más amplia por defecto que también otorga exit.

Trampas

Verificar esto sin que un humano hiciera clic en cada paso fue un rompecabezas en sí mismo. El flujo de cambio empieza con un selector de carpetas nativo del sistema operativo, al que un WebDriver literalmente no puede tocar, no hay DOM que encontrar. Y el comando de reinicio, en cuanto se dispara, mata el proceso exacto al que está conectada la sesión de WebDriver, así que no queda ninguna respuesta de “tuvo éxito” para leer, el proceso que la habría enviado ya no existe.

El truco que funcionó: invocar el comando de reinicio directamente por IPC (evitando el selector llamando a set_vault_root con una ruta fija), y luego leer la forma del fallo. Un error a nivel de conexión, el propio fetch fallando, no un cuerpo de error JSON, significa que el comando se ejecutó y se llevó el proceso consigo. Una respuesta JSON real quejándose de permisos significaría que la capability estaba mal configurada. Confirmé la mitad de “realmente se reinició” de forma independiente con una simple verificación de ps aux buscando un PID nuevo. Eso dio confianza real en el cableado (plugin registrado, capability conectada, comando alcanzable) sin necesitar nunca el diálogo nativo en el circuito para esa parte.

Lo que no pude scriptear, y ni lo intenté: si la app realmente arranca con el vault nuevo después de un reinicio real disparado desde la interfaz real. Ese fue el único lugar donde dejé de scriptear y lo probé a mano en vez de buscar la forma de simularlo.

Resultados

Cambiar de vault ahora muestra un aviso claro de “necesita un reinicio” en vez de cambiar en silencio y en vivo, filtrando acceso de lectura a cada vault que se haya abierto en esa sesión. Es una pequeña regresión de UX, el cambio solía ser instantáneo, a cambio de una propiedad de seguridad que realmente se sostiene. ¿Vale la pena para lo que es, en teoría, una app local de un solo usuario? Discutible. Pero una vez leído el código fuente y confirmado que no había revoke, “aceptar y documentar para siempre” se sintió peor que “costar un reinicio.”

Lecturas relacionadas

Development

Tres puertas que nunca estuvieron cerradas

Un CSP nulo, un valor de configuración unido sin validar a una ruta del sistema de archivos, y un selector de vault que aceptaría la carpeta de inicio como vault, nada roto todavía, todo sin cerrar.

Leer
Development

Dos notas que nadie vio jamás

Greenhouse guardaba fielmente una nota de captura y una nota de traspaso, y no mostraba ninguna de las dos: una no tenía campo en el tipo de wire, la otra tenía un comando funcional que nadie llamaba.

Leer
Development

El escaneo que se ofreció a importarse a sí mismo

Un escaneo de importación que encontró la propia estructura interna de la app, y luego volvió a ofrecer una carpeta que ya había importado - dos versiones de la misma conversación faltante entre capas.

Leer