refactor(web): secure Pico websocket access behind launcher auth

- stop exposing the raw Pico token to the frontend
- add /api/pico/info for non-secret Pico connection metadata
- proxy /pico/ws through the launcher with same-origin and dashboard auth checks
- inject the upstream Pico websocket protocol server-side
- update frontend chat connection flow and Vite websocket proxy path
- refresh related docs and tests
This commit is contained in:
wenjie
2026-04-16 16:47:23 +08:00
parent 6126ede963
commit 4b76196e2c
14 changed files with 253 additions and 171 deletions
@@ -218,6 +218,10 @@ func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig
}
func rejectLauncherDashboardAuth(w http.ResponseWriter, r *http.Request, canonicalPath string) {
if canonicalPath == "/pico/ws" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if strings.HasPrefix(canonicalPath, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
@@ -40,6 +40,7 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
{http.MethodPost, "/api/auth/logout", http.StatusTeapot},
{http.MethodGet, "/api/auth/logout", http.StatusUnauthorized},
{http.MethodGet, "/api/config", http.StatusUnauthorized},
{http.MethodGet, "/pico/ws", http.StatusUnauthorized},
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, nil)
@@ -160,3 +161,22 @@ func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) {
t.Fatalf("bearer auth: status = %d", rec2.Code)
}
}
func TestLauncherDashboardAuth_WebSocketUnauthorizedDoesNotRedirect(t *testing.T) {
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"}
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("next handler should not run without auth")
})
h := LauncherDashboardAuth(cfg, next)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/pico/ws", nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
if got := rec.Header().Get("Location"); got != "" {
t.Fatalf("Location = %q, want empty", got)
}
}