From 4709293c4fc93cf5c78f3e6347b2505f8f0c95ae Mon Sep 17 00:00:00 2001 From: benya Date: Thu, 4 Jun 2026 19:51:47 +0300 Subject: [PATCH] Preserve LuCI login cookie path --- server/internal/httpapi/server.go | 25 ++++++++++++++++++--- server/internal/httpapi/server_test.go | 30 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 0467cf5..c58e17f 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -1179,12 +1179,31 @@ func rewriteLuCIHeaders(header http.Header, prefix string) { header.Set("Location", prefix+location) } for index, cookie := range header.Values("Set-Cookie") { - cookie = strings.ReplaceAll(cookie, "Path=/;", "Path="+prefix+"/;") - cookie = strings.ReplaceAll(cookie, "Path=/ ", "Path="+prefix+"/ ") - header["Set-Cookie"][index] = cookie + header["Set-Cookie"][index] = rewriteLuCICookie(cookie, prefix) } } +func rewriteLuCICookie(cookie, prefix string) string { + parts := strings.Split(cookie, ";") + pathFound := false + for index := 1; index < len(parts); index++ { + attribute := strings.TrimSpace(parts[index]) + if !strings.HasPrefix(strings.ToLower(attribute), "path=") { + continue + } + path := strings.TrimSpace(attribute[len("path="):]) + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + parts[index] = " Path=" + prefix + path + pathFound = true + } + if !pathFound { + parts = append(parts, " Path="+prefix+"/") + } + return strings.Join(parts, ";") +} + func (a *App) handleCloseRemoteSession(w http.ResponseWriter, r *http.Request) { deviceID, sessionID, ok := remoteSessionCloseIDsFromPath(r.URL.Path) if !ok { diff --git a/server/internal/httpapi/server_test.go b/server/internal/httpapi/server_test.go index b6ce3f9..67e7a18 100644 --- a/server/internal/httpapi/server_test.go +++ b/server/internal/httpapi/server_test.go @@ -407,6 +407,7 @@ func TestAgentOperatorSmokeFlow(t *testing.T) { func TestLuCIProxyRequiresActiveSessionAndRewritesPaths(t *testing.T) { var upstreamPath string + var authenticated bool upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Host != "127.0.0.1" { http.Error(w, "rejected host", http.StatusForbidden) @@ -425,6 +426,20 @@ func TestLuCIProxyRequiresActiveSessionAndRewritesPaths(t *testing.T) { t.Fatal("LuCI route cookie leaked to LuCI upstream") } upstreamPath = r.URL.Path + if r.Method == http.MethodPost && r.URL.Path == "/cgi-bin/luci/" { + http.SetCookie(w, &http.Cookie{Name: "sysauth", Value: "session-token", Path: "/cgi-bin/luci"}) + w.Header().Set("Location", "/cgi-bin/luci/admin/status/overview") + w.WriteHeader(http.StatusFound) + return + } + if r.URL.Path == "/cgi-bin/luci/admin/status/overview" { + cookie, err := r.Cookie("sysauth") + authenticated = err == nil && cookie.Value == "session-token" + if !authenticated { + http.Error(w, "login required", http.StatusForbidden) + return + } + } if r.URL.Path == "/luci-static/resources/luci.js" { w.Header().Set("Content-Type", "application/javascript") _, _ = io.WriteString(w, `const untouched = '/cgi-bin/luci';`) @@ -518,6 +533,21 @@ func TestLuCIProxyRequiresActiveSessionAndRewritesPaths(t *testing.T) { if script != `const untouched = '/cgi-bin/luci';` { t.Fatalf("LuCI JavaScript was unexpectedly rewritten: %s", script) } + + loginURL := srv.URL + prefix + "/cgi-bin/luci/" + req, err := http.NewRequest(http.MethodPost, loginURL, strings.NewReader("luci_username=root&luci_password=test")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer operator-test") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK || !authenticated { + t.Fatalf("LuCI login cookie was not preserved, status=%d authenticated=%t", resp.StatusCode, authenticated) + } } func TestServesStaticWebUI(t *testing.T) {