fix(permisos): compara la ruta completa y avisa de los menús que dan 404

Auditoría del sistema de permisos a partir del 404 en /app/doc/paginas.

1. La comparación de permisos usaba solo el último segmento de la URL
   (lo que va después del último '/'). Rompía en las dos direcciones: con
   permiso sobre /app/doc/categorias se entraba a cualquier otra ruta
   terminada en "categorias", y a la vez el permiso parecía no aplicarse
   donde sí correspondía porque el segmento coincidía por casualidad.
   Ahora se compara la ruta completa, cortando en el separador para que
   /app/doc/paginas no habilite /app/doc/paginas-privadas.

2. El submódulo de Documentación nunca se sembró, aunque las rutas y las
   vistas existen desde mayo. Había que crear el ítem del menú a mano, y
   una URL mal tipeada ahí se ve exactamente igual que un permiso mal
   asignado: un 404. Ahora se siembra con la URL correcta.

3. El submódulo "Statuspage" apuntaba a /app/statuspage, que no existe en
   ninguna parte — la página real es la pública /status. Se corrige el
   seed y también el registro ya creado en la base.

4. Chequeo en el arranque que recorre los submódulos de la base y avisa
   cuáles apuntan a una URL sin ruta. Cubre los creados a mano, que es
   justo donde el compilador y los tests no llegan.

5. Test que cruza las URLs sembradas contra las rutas registradas: fue el
   que encontró lo de statuspage.

Queda pendiente y es más grande: de 454 rutas protegidas solo 54 pasan por
MenuMiddleware. El permiso gatea la página HTML pero no los endpoints de
datos — cualquier usuario autenticado puede llamar a
GET /app/doc/loadpaginas o DELETE /app/doc/paginas/:id sin tener el
submódulo asignado. Se reporta antes de tocarlo porque cerrarlo de golpe
puede dejar gente afuera.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-15 09:21:44 -05:00
co-authored by Claude Sonnet 5
parent ad3458bb71
commit 105ab44759
5 changed files with 278 additions and 20 deletions
+15
View File
@@ -198,6 +198,7 @@ func main() {
migrations.SeedPagosExternos()
migrations.SeedAutomatizacionIA()
migrations.SeedUmind()
migrations.SeedDocumentacion()
// Va antes de MigrarUmindAgentes: libera las columnas huérfanas que
// dejó el refactor multi-agente. Sin esto no se puede insertar nada en
// uMind (ver el comentario de la función).
@@ -228,6 +229,20 @@ func main() {
defer services.DetenerCron()
// Cargar rutas
routes.LoadRoutes(app.Http.Server.App)
// Con las rutas ya montadas se puede avisar de los ítems del menú que
// apuntan a una URL inexistente — el síntoma es un 404 que parece un
// problema de permisos.
rutasGET := map[string]bool{}
for _, capa := range app.Http.Server.Stack() {
for _, r := range capa {
if r.Method == "GET" {
rutasGET[r.Path] = true
}
}
}
migrations.VerificarUrlsDeSubmodulos(rutasGET)
app.Http.Route404()
log.Fatal(app.Http.Server.ServeWithGraceFullShutdown())
}
+90 -1
View File
@@ -3,6 +3,7 @@ package migrations
import (
"fmt"
"log"
"strings"
"time"
"github.com/sujit-baniya/fiber-boilerplate/app"
@@ -239,9 +240,15 @@ func SeedStatuspage() {
}
entries := []struct{ title, desc, url string }{
{"Statuspage", "Monitoreo de estado de servicios vía Atlassian Statuspage", "/app/statuspage"},
// La página real es la pública /status (rest/routes/publicas.go):
// /app/statuspage nunca existió y el ítem daba 404 al entrar.
{"Statuspage", "Monitoreo de estado de servicios vía Atlassian Statuspage", "/status"},
}
// La URL vieja apuntaba a /app/statuspage, que no existe: se corrige el
// registro ya creado en vez de dejar un ítem de menú que da 404.
db.Model(&models.Submodules{}).Where("url = ?", "/app/statuspage").Update("url", "/status")
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
@@ -1533,3 +1540,85 @@ func LiberarColumnasHuerfanasUmind() {
log.Printf("[MIGRACION] %s.%s ya no es NOT NULL (columna huérfana del refactor multi-agente)", h.Tabla, h.Columna)
}
}
// VerificarUrlsDeSubmodulos avisa en el arranque de los ítems del menú cuya
// URL no corresponde a ninguna ruta registrada.
//
// Un submódulo es una URL escrita a mano (en un seed o desde /app/submodules).
// Si no coincide con una ruta real, el ítem igual aparece en el menú y se le
// puede asignar permiso a un rol — y recién al hacer clic aparece un 404, sin
// nada que explique por qué. Esto lo convierte en una línea de log al
// arrancar, en vez de un misterio en producción.
//
// Solo informa: no borra ni modifica nada, porque una URL "rota" puede ser una
// ruta servida por otro lado.
func VerificarUrlsDeSubmodulos(rutasGET map[string]bool) {
var subs []models.Submodules
if err := app.Http.Database.DB.Find(&subs).Error; err != nil {
log.Printf("[MENU] no se pudieron revisar las URLs de los submódulos: %v", err)
return
}
rotos := 0
for _, s := range subs {
url := strings.TrimSpace(s.Url)
if url == "" {
log.Printf("[MENU] el submódulo %q (ID %d) no tiene URL", s.Title, s.ID)
rotos++
continue
}
if !rutasGET[url] {
log.Printf("[MENU] el submódulo %q (ID %d) apunta a %q, que no es ninguna ruta: va a dar 404", s.Title, s.ID, url)
rotos++
}
}
if rotos == 0 {
log.Printf("[MENU] %d submódulos revisados, todas las URLs resuelven", len(subs))
} else {
log.Printf("[MENU] %d de %d submódulos apuntan a una URL inexistente (ver líneas anteriores)", rotos, len(subs))
}
}
// SeedDocumentacion registra los submódulos de Documentación.
//
// Existían las rutas y las vistas pero nunca el seed, así que había que crear
// el ítem del menú a mano desde /app/submodules — y una URL mal tipeada ahí es
// indistinguible de un permiso mal asignado: en los dos casos se ve un 404.
func SeedDocumentacion() {
db := app.Http.Database.DB
var modulo models.Modules
if err := db.Where("title = ?", "Documentación").First(&modulo).Error; err != nil {
modulo = models.Modules{
Title: "Documentación",
Description: "Páginas y categorías de documentación interna",
ModifiedAt: time.Now(),
}
if err := db.Create(&modulo).Error; err != nil {
log.Printf("[SEED] Error creando módulo Documentación: %v", err)
return
}
}
entries := []struct{ title, desc, url string }{
{"Doc: Páginas", "Páginas de documentación", "/app/doc/paginas"},
{"Doc: Categorías", "Categorías de documentación", "/app/doc/categorias"},
}
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
}
}
}
+34 -19
View File
@@ -87,25 +87,15 @@ func MenuMiddleware(c *fiber.Ctx) error {
return c.Next()
}
// Verificar si la URL de la solicitud está en la lista de URLs permitidas
requestURL := c.Path()
// Obtener el índice del último '/' en la URL de la solicitud
lastSlashIndex := strings.LastIndex(requestURL, "/")
if lastSlashIndex != -1 {
// Obtener solo la parte de la URL después del último '/'
requestURL = requestURL[lastSlashIndex+1:]
}
// Verificar si la URL modificada está en la lista de URLs permitidas
urlAllowed := false
for _, url := range urls {
// Comparar solo la parte de la URL después del último '/'
if requestURL == url[strings.LastIndex(url, "/")+1:] {
urlAllowed = true
break
}
}
// Se compara la ruta COMPLETA contra las URLs permitidas.
//
// Antes se comparaba solo el último segmento (lo que va después del último
// '/'), y eso rompía en las dos direcciones: dos rutas distintas que
// terminan igual quedaban indistinguibles — con permiso sobre
// /app/doc/categorias se entraba a cualquier otra ruta terminada en
// "categorias" — y a la vez el permiso parecía no aplicarse donde sí
// debía, porque el segmento coincidía por casualidad.
urlAllowed := PuedeVerRuta(c.Path(), urls)
// Si la URL no está permitida, devolver un error
if !urlAllowed {
@@ -118,3 +108,28 @@ func MenuMiddleware(c *fiber.Ctx) error {
// Continuar con la siguiente función de middleware o manejador
return c.Next()
}
// PuedeVerRuta decide si una ruta está cubierta por alguna de las URLs que el
// rol tiene asignadas.
//
// Coincide la ruta exacta o cualquier ruta por debajo de ella: quien tiene
// /app/doc/paginas también puede entrar a /app/doc/paginas/7, que es la misma
// pantalla con un detalle. No coincide /app/doc/paginas-privadas, porque el
// corte se hace en el separador y no en el prefijo de texto — si no, un
// permiso abriría rutas vecinas que solo comparten el comienzo del nombre.
func PuedeVerRuta(ruta string, permitidas []string) bool {
ruta = strings.TrimRight(ruta, "/")
if ruta == "" {
ruta = "/"
}
for _, u := range permitidas {
u = strings.TrimRight(strings.TrimSpace(u), "/")
if u == "" {
continue
}
if ruta == u || strings.HasPrefix(ruta, u+"/") {
return true
}
}
return false
}
+49
View File
@@ -0,0 +1,49 @@
package middlewares
import "testing"
// El permiso comparaba solo el último segmento de la URL, así que dos rutas
// distintas terminadas igual eran indistinguibles. Estos casos fijan que la
// comparación es sobre la ruta completa y corta en el separador.
func TestPuedeVerRuta(t *testing.T) {
permisos := []string{"/app/doc/paginas", "/app/clientes", "/status"}
casos := []struct {
ruta string
esperado bool
porque string
}{
{"/app/doc/paginas", true, "coincidencia exacta"},
{"/app/doc/paginas/7", true, "el detalle cuelga de la pantalla permitida"},
{"/app/doc/paginas/", true, "la barra final no cambia nada"},
{"/app/clientes", true, "otra pantalla permitida"},
{"/status", true, "una ruta pública también puede ser un ítem del menú"},
{"/app/doc/categorias", false, "otra pantalla del mismo módulo"},
{"/app/umind", false, "sin permiso"},
{"/app/doc", false, "el padre no se hereda del hijo"},
// El bug original: comparaba solo lo que va después del último '/'.
{"/app/otro/paginas", false, "termina igual pero es otra ruta"},
// El corte va en el separador, no en el prefijo de texto.
{"/app/doc/paginas-privadas", false, "solo comparte el comienzo del nombre"},
{"/app/clientes-vip", false, "prefijo parecido, ruta distinta"},
}
for _, cas := range casos {
if got := PuedeVerRuta(cas.ruta, permisos); got != cas.esperado {
t.Errorf("PuedeVerRuta(%q) = %v, esperaba %v — %s", cas.ruta, got, cas.esperado, cas.porque)
}
}
}
// Sin permisos no se entra a ningún lado (los administradores se resuelven
// antes, con user.IsAdmin).
func TestPuedeVerRutaSinPermisos(t *testing.T) {
if PuedeVerRuta("/app/clientes", nil) {
t.Error("un rol sin submódulos no debería acceder a nada")
}
if PuedeVerRuta("/app/clientes", []string{"", " "}) {
t.Error("una URL vacía no debería habilitar nada")
}
}
+90
View File
@@ -0,0 +1,90 @@
package routes
import (
"os"
"regexp"
"sort"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
)
// Un submódulo del menú es una URL escrita a mano en el seed. Si no coincide
// con ninguna ruta registrada, el ítem aparece en el menú, se le puede asignar
// permiso a un rol, y al hacer clic da 404 — que es exactamente el síntoma
// reportado en /app/doc/paginas. El compilador no ve nada de esto porque son
// cadenas sueltas en dos archivos distintos.
func TestUrlsDeSubmodulosTienenRuta(t *testing.T) {
rutas := rutasGETRegistradas(t)
urls, err := urlsSembradas()
if err != nil {
t.Skipf("no se pudo leer el seed: %v", err)
}
if len(urls) == 0 {
t.Fatal("no se encontró ninguna URL de submódulo en el seed")
}
var rotas []string
for _, u := range urls {
if !rutas[u] {
rotas = append(rotas, u)
}
}
sort.Strings(rotas)
for _, u := range rotas {
t.Errorf("el submódulo %q no tiene ruta registrada: el menú lo muestra y da 404 al entrar", u)
}
}
// rutasGETRegistradas devuelve el conjunto de rutas GET que sirven una página.
func rutasGETRegistradas(t *testing.T) map[string]bool {
t.Helper()
// LoadRoutes monta todos los grupos: un submódulo puede apuntar a una ruta
// pública (ej. /status) y no solo a /app/*.
app := fiber.New()
LoadRoutes(app)
out := map[string]bool{}
for _, capa := range app.Stack() {
for _, r := range capa {
if r.Method == "GET" {
out[r.Path] = true
}
}
}
return out
}
// Solo las tuplas {título, descripción, url} de los slices de entries y las
// asignaciones Url:. Buscar cualquier "/app/..." suelto daba falsos positivos
// con las líneas que corrigen una URL vieja en la base.
var reURLSubmodulo = regexp.MustCompile(`\{"[^"]*",\s*"[^"]*",\s*"(/[a-z0-9/_-]+)"\}|Url:\s*"(/[a-z0-9/_-]+)"`)
// urlsSembradas saca las URLs de submódulo del seed. Se lee el fuente porque
// los seeds son literales en el código, no datos que se puedan consultar.
func urlsSembradas() ([]string, error) {
b, err := os.ReadFile("../../migrations/migrate.go")
if err != nil {
return nil, err
}
texto := string(b)
vistas := map[string]bool{}
var out []string
for _, m := range reURLSubmodulo.FindAllStringSubmatch(texto, -1) {
u := m[1]
if u == "" {
u = m[2]
}
// Solo interesan las que se usan como Url de un submódulo; las de
// redirección o comparación quedan cubiertas igual y no molestan.
if vistas[u] || strings.Contains(u, "//") {
continue
}
vistas[u] = true
out = append(out, u)
}
return out, nil
}