From f5b2ce748210ced16b7f7fb9001737366c828137 Mon Sep 17 00:00:00 2001 From: danmobot Date: Tue, 23 Jun 2026 02:23:06 +0100 Subject: [PATCH 1/3] fix: reject cross-site launcher setup requests --- web/backend/api/auth.go | 61 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go index da07b76c0..9104eeb17 100644 --- a/web/backend/api/auth.go +++ b/web/backend/api/auth.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "github.com/sipeed/picoclaw/web/backend/middleware" @@ -89,6 +90,60 @@ func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, er return h.store.IsInitialized(ctx) } +func launcherSetupCrossSite(r *http.Request) bool { + fetchSite := strings.ToLower(strings.TrimSpace(r.Header.Get("Sec-Fetch-Site"))) + if fetchSite == "cross-site" { + return true + } + + if origin := strings.TrimSpace(r.Header.Get("Origin")); origin != "" { + return !sameLauncherRequestOrigin(r, origin) + } + + if referer := strings.TrimSpace(r.Header.Get("Referer")); referer != "" { + return !sameLauncherRequestOrigin(r, referer) + } + + return false +} + +func sameLauncherRequestOrigin(r *http.Request, raw string) bool { + if strings.ContainsAny(raw, " \t\r\n") { + return false + } + + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" { + return false + } + + wantScheme := launcherRequestScheme(r) + wantHost := r.Host + if wantHost == "" { + wantHost = r.URL.Host + } + return strings.EqualFold(u.Scheme, wantScheme) && strings.EqualFold(u.Host, wantHost) +} + +func launcherRequestScheme(r *http.Request) string { + if proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); proto != "" { + if i := strings.IndexByte(proto, ','); i >= 0 { + proto = proto[:i] + } + proto = strings.ToLower(strings.TrimSpace(proto)) + if proto == "http" || proto == "https" { + return proto + } + } + if r.TLS != nil { + return "https" + } + if r.URL != nil && r.URL.Scheme != "" { + return r.URL.Scheme + } + return "http" +} + func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var body launcherAuthLoginBody @@ -198,6 +253,12 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + if launcherSetupCrossSite(r) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"cross-site setup request rejected"}`)) + return + } + if h.store == nil { w.WriteHeader(http.StatusServiceUnavailable) if h.storeErr != nil { From 1758eea94853f4c2a6fe2ee829a43267f2b14c28 Mon Sep 17 00:00:00 2001 From: danmobot Date: Tue, 23 Jun 2026 02:24:53 +0100 Subject: [PATCH 2/3] test: cover launcher setup csrf guard --- web/backend/api/auth_csrf_test.go | 63 +++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 web/backend/api/auth_csrf_test.go diff --git a/web/backend/api/auth_csrf_test.go b/web/backend/api/auth_csrf_test.go new file mode 100644 index 000000000..50b0fefd5 --- /dev/null +++ b/web/backend/api/auth_csrf_test.go @@ -0,0 +1,63 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestLauncherAuthSetupRejectsCrossSiteFirstRun(t *testing.T) { + store := &fakePasswordStore{} + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: "session-cookie-value", + PasswordStore: store, + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "http://127.0.0.1:18800/api/auth/setup", + strings.NewReader(`{"password":"CrossSitePwn123!","confirm":"CrossSitePwn123!"}`), + ) + req.Header.Set("Origin", "https://evil.example") + req.Header.Set("Referer", "https://evil.example/attack") + req.Header.Set("Sec-Fetch-Site", "cross-site") + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("cross-site setup code = %d body=%s", rec.Code, rec.Body.String()) + } + if store.initialized || store.password != "" { + t.Fatalf("cross-site setup mutated store: initialized=%v password=%q", store.initialized, store.password) + } +} + +func TestLauncherAuthSetupAllowsSameOriginFirstRun(t *testing.T) { + store := &fakePasswordStore{} + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: "session-cookie-value", + PasswordStore: store, + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "http://127.0.0.1:18800/api/auth/setup", + strings.NewReader(`{"password":"LocalSetup123!","confirm":"LocalSetup123!"}`), + ) + req.Header.Set("Origin", "http://127.0.0.1:18800") + req.Header.Set("Sec-Fetch-Site", "same-origin") + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("same-origin setup code = %d body=%s", rec.Code, rec.Body.String()) + } + if !store.initialized || store.password != "LocalSetup123!" { + t.Fatalf("same-origin setup store: initialized=%v password=%q", store.initialized, store.password) + } +} From 0c404869ae29b72e2fc4b400229df19ece9518a1 Mon Sep 17 00:00:00 2001 From: danmobot <270805001+danmobot@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:41:28 +0100 Subject: [PATCH 3/3] fix: restore openai compat log import --- pkg/providers/openai_compat/provider.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index c12a8348f..6a1b73d8a 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "log" "maps" "net/http" "net/url"