From 451baf6096d725435428b84418b126bf18039d86 Mon Sep 17 00:00:00 2001 From: "Robin A. Plate" Date: Mon, 28 Sep 2026 22:57:40 +0200 Subject: [PATCH] fix: update session cookie handling in txadmin client --- internal/watcher/txadmin/client.go | 72 ++++++++++++-------- internal/watcher/txadmin/client_test.go | 90 +++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 27 deletions(-) create mode 100644 internal/watcher/txadmin/client_test.go diff --git a/internal/watcher/txadmin/client.go b/internal/watcher/txadmin/client.go index c0f2da4..e6eeabf 100644 --- a/internal/watcher/txadmin/client.go +++ b/internal/watcher/txadmin/client.go @@ -91,39 +91,57 @@ func (c *Client) Login() error { } c.csrfToken = loginResp.CSRFToken - // Manually extract session cookie from Set-Cookie header - // txAdmin uses cookie names like "tx:abc123" which violate RFC 6265 - // and are rejected by Go's cookie jar - setCookieHeaders := resp.Header["Set-Cookie"] + // Manually extract session cookie from Set-Cookie header. + // txAdmin cookie names contain ":" (legacy "tx:", current + // "txa:sess:"), which violate RFC 6265 and are rejected + // by Go's cookie jar. + cookieName, cookieValue, ok := sessionCookieFromSetCookie(resp.Header["Set-Cookie"]) + if !ok { + return fmt.Errorf("no session cookie received from txAdmin") + } + + c.sessionCookie = cookieName + "=" + cookieValue + c.session = &Session{ + BaseURL: c.baseURL, + CookieName: cookieName, + Cookie: cookieValue, + CSRFToken: c.csrfToken, + ExpiresAt: time.Now().Add(23 * time.Hour), + } + + return nil +} + +// sessionCookieFromSetCookie finds the txAdmin session cookie. +// It ignores other txAdmin cookies such as "txa:theme". +func sessionCookieFromSetCookie(setCookieHeaders []string) (name, value string, ok bool) { for _, setCookie := range setCookieHeaders { - // Format: "tx:abc123=value; path=/; ..." - // We need to extract "tx:abc123=value" parts := strings.Split(setCookie, ";") - if len(parts) > 0 { - cookiePart := strings.TrimSpace(parts[0]) - if strings.HasPrefix(cookiePart, "tx:") { - c.sessionCookie = cookiePart - // Also extract name and value for session caching - eqIdx := strings.Index(cookiePart, "=") - if eqIdx > 0 { - c.session = &Session{ - BaseURL: c.baseURL, - CookieName: cookiePart[:eqIdx], - Cookie: cookiePart[eqIdx+1:], - CSRFToken: c.csrfToken, - ExpiresAt: time.Now().Add(23 * time.Hour), - } - } - break - } + if len(parts) == 0 { + continue + } + cookiePart := strings.TrimSpace(parts[0]) + eqIdx := strings.Index(cookiePart, "=") + if eqIdx <= 0 { + continue } + cookieName := cookiePart[:eqIdx] + cookieValue := cookiePart[eqIdx+1:] + if cookieValue == "" || !isTxAdminSessionCookie(cookieName) { + continue + } + return cookieName, cookieValue, true } + return "", "", false +} - if c.sessionCookie == "" { - return fmt.Errorf("no session cookie received from txAdmin") +func isTxAdminSessionCookie(name string) bool { + // txAdmin < 8.1 used "tx:". + if strings.HasPrefix(name, "tx:") { + return true } - - return nil + // txAdmin >= 8.1 uses "txa:sess" or "txa:sess:". + return name == "txa:sess" || strings.HasPrefix(name, "txa:sess:") } // extractCSRFFromCookies tries to get CSRF token from cookies diff --git a/internal/watcher/txadmin/client_test.go b/internal/watcher/txadmin/client_test.go new file mode 100644 index 0000000..7f74423 --- /dev/null +++ b/internal/watcher/txadmin/client_test.go @@ -0,0 +1,90 @@ +package txadmin + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestSessionCookieFromSetCookie(t *testing.T) { + tests := []struct { + name string + headers []string + want string + ok bool + }{ + { + name: "current txAdmin session cookie", + headers: []string{"txa:sess:abcdef123456=11111111-1111-1111-1111-111111111111; Path=/; HttpOnly"}, + want: "txa:sess:abcdef123456", + ok: true, + }, + { + name: "theme cookie is ignored in favor of session", + headers: []string{ + "txa:theme=dark; Path=/", + "txa:sess:abcdef123456=11111111-1111-1111-1111-111111111111; Path=/; HttpOnly", + }, + want: "txa:sess:abcdef123456", + ok: true, + }, + { + name: "legacy profile cookie", + headers: []string{"tx:default=legacy-session; Path=/"}, + want: "tx:default", + ok: true, + }, + { + name: "theme cookie alone is not a session", + headers: []string{"txa:theme=dark; Path=/"}, + ok: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotName, _, ok := sessionCookieFromSetCookie(tt.headers) + if ok != tt.ok { + t.Fatalf("ok = %v, want %v", ok, tt.ok) + } + if gotName != tt.want { + t.Fatalf("cookie name = %q, want %q", gotName, tt.want) + } + }) + } +} + +func TestLoginAcceptsCurrentTxAdminSessionCookie(t *testing.T) { + const csrf = "csrf-token" + const sessID = "11111111-1111-1111-1111-111111111111" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/auth/password" || r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + w.Header().Add("Set-Cookie", "txa:theme=dark; Path=/") + w.Header().Add("Set-Cookie", "txa:sess:abcdef123456="+sessID+"; Path=/; HttpOnly") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"csrfToken":"` + csrf + `"}`)) + })) + t.Cleanup(srv.Close) + + client, err := NewClient(srv.URL, "user", "pass") + if err != nil { + t.Fatal(err) + } + if err := client.Login(); err != nil { + t.Fatal(err) + } + + if client.csrfToken != csrf { + t.Fatalf("csrf = %q, want %q", client.csrfToken, csrf) + } + if client.session == nil || client.session.CookieName != "txa:sess:abcdef123456" || client.session.Cookie != sessID { + t.Fatalf("session = %#v", client.session) + } + if client.sessionCookie != "txa:sess:abcdef123456="+sessID { + t.Fatalf("session cookie = %q", client.sessionCookie) + } +}