feat(web): protect launcher dashboard with token and SPA login (#1953)

Add token-based authentication for the Launcher's embedded Web Dashboard.

- Ephemeral token generated in-memory each run (or via PICOCLAW_LAUNCHER_TOKEN env var)
- HMAC-SHA256 session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS)
- Bearer token support for API/script access
- Rate limiting on login (10 attempts/IP/min)
- Referrer-Policy: no-referrer on all responses
- POST-only logout with JSON content-type (CSRF-safe)
- System tray "Copy dashboard token" action
- Login page shows contextual help (console/tray/log file path)
- Path traversal protection via path.Clean
- X-Forwarded-Host/Port/Proto support for reverse proxy deployments
- Full i18n support (English, Chinese)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zeed zhao
2026-03-29 13:11:43 +08:00
committed by GitHub
parent 27f638e909
commit 6ea364e67d
44 changed files with 1617 additions and 45 deletions
+142
View File
@@ -0,0 +1,142 @@
package api
import (
"crypto/subtle"
"encoding/json"
"io"
"net/http"
"strings"
"github.com/sipeed/picoclaw/web/backend/middleware"
)
// LauncherAuthRouteOpts configures dashboard token login handlers.
type LauncherAuthRouteOpts struct {
DashboardToken string
SessionCookie string
SecureCookie func(*http.Request) bool
// TokenHelp is returned on unauthenticated /api/auth/status responses (no secrets).
TokenHelp LauncherAuthTokenHelp
}
// LauncherAuthTokenHelp tells the login UI where users can find the dashboard token.
type LauncherAuthTokenHelp struct {
EnvVarName string `json:"env_var_name"`
LogFileAbs string `json:"log_file,omitempty"`
TrayCopyMenu bool `json:"tray_copy_menu"`
ConsoleStdout bool `json:"console_stdout"`
}
type launcherAuthLoginBody struct {
Token string `json:"token"`
}
type launcherAuthStatusResponse struct {
Authenticated bool `json:"authenticated"`
TokenHelp *LauncherAuthTokenHelp `json:"token_help,omitempty"`
}
// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status.
func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) {
secure := opts.SecureCookie
if secure == nil {
secure = middleware.DefaultLauncherDashboardSecureCookie
}
h := &launcherAuthHandlers{
token: opts.DashboardToken,
sessionCookie: opts.SessionCookie,
secureCookie: secure,
tokenHelp: opts.TokenHelp,
loginLimit: newLoginRateLimiter(),
}
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
mux.HandleFunc("POST /api/auth/logout", h.handleLogout)
mux.HandleFunc("GET /api/auth/status", h.handleStatus)
}
type launcherAuthHandlers struct {
token string
sessionCookie string
secureCookie func(*http.Request) bool
tokenHelp LauncherAuthTokenHelp
loginLimit *loginRateLimiter
}
func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var body launcherAuthLoginBody
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid JSON"}`))
return
}
ip := clientIPForLimiter(r)
if !h.loginLimit.allow(ip) {
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"error":"too many login attempts"}`))
return
}
in := strings.TrimSpace(body.Token)
if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid token"}`))
return
}
middleware.SetLauncherDashboardSessionCookie(w, r, h.sessionCookie, h.secureCookie)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
_, _ = w.Write([]byte(`{"error":"method not allowed"}`))
return
}
ct := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type")))
if !strings.HasPrefix(ct, "application/json") {
w.WriteHeader(http.StatusUnsupportedMediaType)
_, _ = w.Write([]byte(`{"error":"Content-Type must be application/json"}`))
return
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, logoutBodyMaxBytes))
if err := dec.Decode(&struct{}{}); err != nil && err != io.EOF {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid JSON body"}`))
return
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid JSON body"}`))
return
}
middleware.ClearLauncherDashboardSessionCookie(w, r, h.secureCookie)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ok := false
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
}
if ok {
_, _ = w.Write([]byte(`{"authenticated":true}`))
return
}
resp := launcherAuthStatusResponse{
Authenticated: false,
TokenHelp: &h.tokenHelp,
}
enc, err := json.Marshal(resp)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"internal error"}`))
return
}
_, _ = w.Write(enc)
}
+59
View File
@@ -0,0 +1,59 @@
package api
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
const (
loginAttemptsPerIP = 10
loginAttemptWindow = time.Minute
logoutBodyMaxBytes = 4096
)
// loginRateLimiter limits POST /api/auth/login attempts per IP per minute.
type loginRateLimiter struct {
mu sync.Mutex
now func() time.Time
byIP map[string][]time.Time
}
func newLoginRateLimiter() *loginRateLimiter {
return &loginRateLimiter{
now: time.Now,
byIP: make(map[string][]time.Time),
}
}
// allow reserves a slot for this request; false means rate limit exceeded.
func (l *loginRateLimiter) allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
cutoff := now.Add(-loginAttemptWindow)
times := l.byIP[ip]
var kept []time.Time
for _, ts := range times {
if ts.After(cutoff) {
kept = append(kept, ts)
}
}
if len(kept) >= loginAttemptsPerIP {
l.byIP[ip] = kept
return false
}
kept = append(kept, now)
l.byIP[ip] = kept
return true
}
func clientIPForLimiter(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return strings.TrimSpace(r.RemoteAddr)
}
return host
}
+218
View File
@@ -0,0 +1,218 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/web/backend/middleware"
)
func TestLauncherAuthLoginAndStatus(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = 0x55
}
const tok = "dashboard-test-token-9"
sess := middleware.SessionCookieValue(key, tok)
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{
EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
LogFileAbs: "/tmp/launcher.log",
TrayCopyMenu: true,
ConsoleStdout: false,
},
})
t.Run("status_unauthenticated", func(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status code = %d", rec.Code)
}
var body struct {
Authenticated bool `json:"authenticated"`
TokenHelp *LauncherAuthTokenHelp `json:"token_help"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Authenticated || body.TokenHelp == nil {
t.Fatalf("unexpected body: %+v", body)
}
if body.TokenHelp.EnvVarName != "PICOCLAW_LAUNCHER_TOKEN" || body.TokenHelp.LogFileAbs != "/tmp/launcher.log" {
t.Fatalf("token_help = %+v", body.TokenHelp)
}
})
t.Run("login_ok", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"token":"`+tok+`"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:12345"
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
}
cookies := rec.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != middleware.LauncherDashboardCookieName {
t.Fatalf("cookies = %#v", cookies)
}
})
t.Run("status_authenticated", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)
req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess})
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status code = %d", rec.Code)
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"authenticated":true`)) {
t.Fatalf("body = %s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "token_help") {
t.Fatalf("authenticated response should omit token_help: %s", rec.Body.String())
}
})
}
func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"},
})
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/logout", nil))
if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusNotFound {
t.Fatalf("GET logout: code = %d (expected 404 or 405)", rec.Code)
}
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusUnsupportedMediaType {
t.Fatalf("wrong content-type: code = %d body=%s", rec2.Code, rec2.Body.String())
}
rec3 := httptest.NewRecorder()
req3 := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}`))
req3.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec3, req3)
if rec3.Code != http.StatusOK {
t.Fatalf("POST json logout: code = %d", rec3.Code)
}
}
func TestLauncherAuthLoginRateLimit(t *testing.T) {
key := make([]byte, 32)
const tok = "rate-limit-tok-xxxxxxxx"
sess := middleware.SessionCookieValue(key, tok)
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
// 11 failing logins by wrong token; each consumes allow() slot after valid JSON.
wrongBody := `{"token":"wrong"}`
for i := 0; i < loginAttemptsPerIP; i++ {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.168.5.5:9999"
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("iter %d: want 401 got %d %s", i, rec.Code, rec.Body.String())
}
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.168.5.5:9999"
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("11th attempt: want 429 got %d %s", rec.Code, rec.Body.String())
}
}
func TestLoginRateLimiterWindow(t *testing.T) {
l := newLoginRateLimiter()
t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
l.now = func() time.Time { return t0 }
for i := 0; i < loginAttemptsPerIP; i++ {
if !l.allow("ip") {
t.Fatalf("want allow at %d", i)
}
}
if l.allow("ip") {
t.Fatal("want deny on 11th")
}
l.now = func() time.Time { return t0.Add(loginAttemptWindow + time.Second) }
if !l.allow("ip") {
t.Fatal("want allow after window")
}
}
func TestReferrerPolicyMiddleware(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
h := middleware.ReferrerPolicyNoReferrer(next)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" {
t.Fatalf("Referrer-Policy = %q", got)
}
}
func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
req.Header.Set("Content-Type", "application/json")
req.Body = http.NoBody
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
}
func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400 got %d %s", rec.Code, rec.Body.String())
}
}
+111 -7
View File
@@ -93,16 +93,120 @@ func requestWSScheme(r *http.Request) string {
return "ws"
}
func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string {
host := h.effectiveGatewayBindHost(cfg)
if host == "" || host == "0.0.0.0" {
host = requestHostName(r)
// requestHTTPScheme returns http or https for URLs that are not WebSockets (e.g. SSE).
func requestHTTPScheme(r *http.Request) string {
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0]))
if proto == "https" || proto == "wss" {
return "https"
}
if proto == "http" || proto == "ws" {
return "http"
}
}
// Use web server port instead of gateway port to avoid exposing extra ports
// The WebSocket connection will be proxied by the backend to the gateway
if r.TLS != nil {
return "https"
}
return "http"
}
// forwardedHostFirst returns the client-visible host from reverse-proxy / tunnel headers
// (e.g. VS Code port forwarding, nginx). Empty if unset.
func forwardedHostFirst(r *http.Request) string {
raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
if raw == "" {
raw = forwardedRFC7239Host(r)
}
if raw == "" {
return ""
}
if i := strings.IndexByte(raw, ','); i >= 0 {
raw = strings.TrimSpace(raw[:i])
}
return raw
}
// forwardedRFC7239Host parses host= from the first Forwarded header element (RFC 7239).
func forwardedRFC7239Host(r *http.Request) string {
v := strings.TrimSpace(r.Header.Get("Forwarded"))
if v == "" {
return ""
}
first := strings.TrimSpace(strings.Split(v, ",")[0])
for _, part := range strings.Split(first, ";") {
part = strings.TrimSpace(part)
low := strings.ToLower(part)
if !strings.HasPrefix(low, "host=") {
continue
}
val := strings.TrimSpace(part[strings.IndexByte(part, '=')+1:])
if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' {
val = val[1 : len(val)-1]
}
return val
}
return ""
}
// forwardedPortFirst returns the first X-Forwarded-Port value, or empty.
func forwardedPortFirst(r *http.Request) string {
raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Port"))
if raw == "" {
return ""
}
if i := strings.IndexByte(raw, ','); i >= 0 {
raw = strings.TrimSpace(raw[:i])
}
return raw
}
// clientVisiblePort picks the TCP port the browser uses to reach this app (after proxies).
// Used by picoWebUIAddr → buildWsURL / buildPicoEventsURL / buildPicoSendURL so WebSocket and
// HTTP URLs match the dashboard page origin (cookies / token flow behind tunnels and reverse proxies).
func clientVisiblePort(r *http.Request, serverListenPort int) string {
if p := forwardedPortFirst(r); p != "" {
return p
}
if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" {
return port
}
if requestHTTPScheme(r) == "https" {
return "443"
}
return strconv.Itoa(serverListenPort)
}
// joinClientVisibleHostPort builds host:port for absolute URLs returned to the browser.
func joinClientVisibleHostPort(r *http.Request, host string, serverListenPort int) string {
if h, p, err := net.SplitHostPort(host); err == nil {
return net.JoinHostPort(h, p)
}
return net.JoinHostPort(host, clientVisiblePort(r, serverListenPort))
}
// picoWebUIAddr is host:port for URLs returned to the browser (/pico/ws, /pico/events, /pico/send).
// It must match the HTTP Host the client used (or X-Forwarded-*), not cfg.Gateway.Host — otherwise
// e.g. page on localhost with ws_url 127.0.0.1 omits cookies and the dashboard auth handshake fails.
func (h *Handler) picoWebUIAddr(r *http.Request) string {
wsPort := h.serverPort
if wsPort == 0 {
wsPort = 18800 // default web server port
}
return requestWSScheme(r) + "://" + net.JoinHostPort(host, strconv.Itoa(wsPort)) + "/pico/ws"
if fwdHost := forwardedHostFirst(r); fwdHost != "" {
return joinClientVisibleHostPort(r, fwdHost, wsPort)
}
host := requestHostName(r)
return net.JoinHostPort(host, strconv.Itoa(wsPort))
}
func (h *Handler) buildWsURL(r *http.Request) string {
return requestWSScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/ws"
}
func (h *Handler) buildPicoEventsURL(r *http.Request) string {
return requestHTTPScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/events"
}
func (h *Handler) buildPicoSendURL(r *http.Request) string {
return requestHTTPScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/send"
}
+58 -4
View File
@@ -51,9 +51,16 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
req.Host = "192.168.1.9:18800"
if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18800/pico/ws" {
if got := h.buildWsURL(req); got != "ws://192.168.1.9:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18800/pico/ws")
}
if got := h.buildPicoEventsURL(req); got != "http://192.168.1.9:18800/pico/events" {
t.Fatalf("buildPicoEventsURL() = %q, want %q", got, "http://192.168.1.9:18800/pico/events")
}
if got := h.buildPicoSendURL(req); got != "http://192.168.1.9:18800/pico/send" {
t.Fatalf("buildPicoSendURL() = %q, want %q", got, "http://192.168.1.9:18800/pico/send")
}
}
func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
@@ -147,7 +154,7 @@ func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) {
req.Host = "chat.example.com"
req.Header.Set("X-Forwarded-Proto", "https")
if got := h.buildWsURL(req, cfg); got != "wss://chat.example.com:18800/pico/ws" {
if got := h.buildWsURL(req); got != "wss://chat.example.com:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws")
}
}
@@ -164,11 +171,45 @@ func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) {
req.Host = "secure.example.com"
req.TLS = &tls.ConnectionState{}
if got := h.buildWsURL(req, cfg); got != "wss://secure.example.com:18800/pico/ws" {
if got := h.buildWsURL(req); got != "wss://secure.example.com:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws")
}
}
func TestBuildPicoURLsPreferXForwardedHost(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
launcherPath := launcherconfig.PathForAppConfig(configPath)
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
Port: 18800,
Public: true,
}); err != nil {
t.Fatalf("launcherconfig.Save() error = %v", err)
}
h := NewHandler(configPath)
h.SetServerOptions(18800, false, false, nil)
cfg := config.DefaultConfig()
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 18790
req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/token", nil)
req.Host = "127.0.0.1:18800"
req.Header.Set("X-Forwarded-Host", "vscode-tunnel.example.com")
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Port", "443")
if got := h.buildPicoEventsURL(req); got != "https://vscode-tunnel.example.com:443/pico/events" {
t.Fatalf("buildPicoEventsURL() = %q, want %q", got, "https://vscode-tunnel.example.com:443/pico/events")
}
if got := h.buildPicoSendURL(req); got != "https://vscode-tunnel.example.com:443/pico/send" {
t.Fatalf("buildPicoSendURL() = %q, want %q", got, "https://vscode-tunnel.example.com:443/pico/send")
}
if got := h.buildWsURL(req); got != "wss://vscode-tunnel.example.com:443/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://vscode-tunnel.example.com:443/pico/ws")
}
}
func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
@@ -182,7 +223,20 @@ func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) {
req.TLS = &tls.ConnectionState{}
req.Header.Set("X-Forwarded-Proto", "http")
if got := h.buildWsURL(req, cfg); got != "ws://chat.example.com:18800/pico/ws" {
if got := h.buildWsURL(req); got != "ws://chat.example.com:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws")
}
}
func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
h.SetServerOptions(18800, false, false, nil)
req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/token", nil)
req.Host = "localhost:18800"
if got := h.buildWsURL(req); got != "ws://localhost:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://localhost:18800/pico/ws")
}
}
+3 -3
View File
@@ -53,7 +53,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
return
}
wsURL := h.buildWsURL(r, cfg)
wsURL := h.buildWsURL(r)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
@@ -81,7 +81,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
return
}
wsURL := h.buildWsURL(r, cfg)
wsURL := h.buildWsURL(r)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
@@ -146,7 +146,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
return
}
wsURL := h.buildWsURL(r, cfg)
wsURL := h.buildWsURL(r)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
+6 -2
View File
@@ -55,8 +55,12 @@ func shutdownApp() {
}
func openBrowser() error {
if serverAddr == "" {
target := browserLaunchURL
if target == "" {
target = serverAddr
}
if target == "" {
return fmt.Errorf("server address not set")
}
return utils.OpenBrowser(serverAddr)
return utils.OpenBrowser(target)
}
+6
View File
@@ -24,6 +24,8 @@ const (
AppTooltip TranslationKey = "AppTooltip"
MenuOpen TranslationKey = "MenuOpen"
MenuOpenTooltip TranslationKey = "MenuOpenTooltip"
MenuCopyToken TranslationKey = "MenuCopyToken"
MenuCopyTokenHint TranslationKey = "MenuCopyTokenHint"
MenuAbout TranslationKey = "MenuAbout"
MenuAboutTooltip TranslationKey = "MenuAboutTooltip"
MenuVersion TranslationKey = "MenuVersion"
@@ -47,6 +49,8 @@ var translations = map[Language]map[TranslationKey]string{
AppTooltip: "%s - Web Console",
MenuOpen: "Open Console",
MenuOpenTooltip: "Open PicoClaw console in browser",
MenuCopyToken: "Copy dashboard token",
MenuCopyTokenHint: "Copy the current web console access token to the clipboard",
MenuAbout: "About",
MenuAboutTooltip: "About PicoClaw",
MenuVersion: "Version: %s",
@@ -64,6 +68,8 @@ var translations = map[Language]map[TranslationKey]string{
AppTooltip: "%s - Web Console",
MenuOpen: "打开控制台",
MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台",
MenuCopyToken: "复制控制台口令",
MenuCopyTokenHint: "将当前 Web 控制台访问口令复制到剪贴板",
MenuAbout: "关于",
MenuAboutTooltip: "关于 PicoClaw",
MenuVersion: "版本: %s",
+35
View File
@@ -1,6 +1,8 @@
package launcherconfig
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net"
@@ -14,6 +16,11 @@ const (
FileName = "launcher-config.json"
// DefaultPort is the default port for the web launcher.
DefaultPort = 18800
// dashboardSigningKeyBytes is the HMAC-SHA256 key size (256 bits).
dashboardSigningKeyBytes = 32
// dashboardTokenEntropyBytes is CSPRNG length before base64 for the per-run dashboard token (256 bits).
dashboardTokenEntropyBytes = 32
)
// Config stores launch parameters for the web backend service.
@@ -41,6 +48,34 @@ func Validate(cfg Config) error {
return nil
}
// EnsureDashboardSecrets returns signing key bytes and the effective dashboard token for this
// process. The signing key is freshly random each call; the token comes from the environment
// variable PICOCLAW_LAUNCHER_TOKEN when set, otherwise a new random token.
func EnsureDashboardSecrets() (effectiveToken string, signingKey []byte, newRandomDashboardToken bool, err error) {
signingKey = make([]byte, dashboardSigningKeyBytes)
if _, err = rand.Read(signingKey); err != nil {
return "", nil, false, err
}
effectiveToken = strings.TrimSpace(os.Getenv("PICOCLAW_LAUNCHER_TOKEN"))
if effectiveToken != "" {
return effectiveToken, signingKey, false, nil
}
tok, genErr := randomDashboardToken()
if genErr != nil {
return "", nil, false, genErr
}
return tok, signingKey, true, nil
}
func randomDashboardToken() (string, error) {
buf := make([]byte, dashboardTokenEntropyBytes)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs.
func NormalizeCIDRs(cidrs []string) []string {
if len(cidrs) == 0 {
+47
View File
@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/web/backend/middleware"
)
func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
@@ -75,6 +77,51 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) {
}
}
func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) {
t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "")
tok, key, newTok, err := EnsureDashboardSecrets()
if err != nil {
t.Fatalf("EnsureDashboardSecrets() error = %v", err)
}
if !newTok || tok == "" || len(key) != dashboardSigningKeyBytes {
t.Fatalf("unexpected first call: newTok=%v tok=%q keyLen=%d", newTok, tok, len(key))
}
mac := middleware.SessionCookieValue(key, tok)
if mac == "" {
t.Fatal("empty session mac")
}
tok2, key2, newTok2, err := EnsureDashboardSecrets()
if err != nil {
t.Fatalf("EnsureDashboardSecrets() second error = %v", err)
}
if !newTok2 {
t.Fatal("second call without env should generate another random token")
}
if tok2 == tok {
t.Fatal("expected a new random dashboard token")
}
if string(key2) == string(key) {
t.Fatal("expected a new signing key")
}
}
func TestEnsureDashboardSecrets_EnvOverridesGenerated(t *testing.T) {
t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "env-only-token-override")
tok, _, newTok, err := EnsureDashboardSecrets()
if err != nil {
t.Fatalf("EnsureDashboardSecrets() error = %v", err)
}
if tok != "env-only-token-override" {
t.Fatalf("token = %q, want env value", tok)
}
if newTok {
t.Fatal("newRandomDashboardToken should be false when env is set")
}
}
func TestNormalizeCIDRs(t *testing.T) {
got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"})
want := []string{"192.168.1.0/24", "10.0.0.0/8"}
+60 -7
View File
@@ -16,6 +16,7 @@ import (
"flag"
"fmt"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
@@ -44,7 +45,12 @@ var (
server *http.Server
serverAddr string
apiHandler *api.Handler
// browserLaunchURL is opened by openBrowser() (auto-open + tray "open console").
// Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use.
browserLaunchURL string
apiHandler *api.Handler
// launcherDashboardTokenForClipboard is read by the system tray "copy token" action (GUI mode).
launcherDashboardTokenForClipboard string
noBrowser *bool
)
@@ -57,7 +63,7 @@ func main() {
console := flag.Bool("console", false, "Console mode, no GUI")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
fmt.Fprintf(os.Stderr, "%s Launcher - A web-based configuration editor\n\n", appName)
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Arguments:\n")
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
@@ -98,8 +104,8 @@ func main() {
defer logger.DisableFileLogging()
}
logger.InfoC("web", fmt.Sprintf("%s Launcher %s starting...", appName, appVersion))
logger.InfoC("web", fmt.Sprintf("PicoClaw Home: %s", picoHome))
logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion))
logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome))
// Set language from command line or auto-detect
if *lang != "" {
@@ -118,7 +124,7 @@ func main() {
}
err = utils.EnsureOnboarded(absPath)
if err != nil {
logger.Errorf("Warning: Failed to initialize PicoClaw config automatically: %v", err)
logger.Errorf("Warning: Failed to initialize %s config automatically: %v", appName, err)
}
var explicitPort bool
@@ -156,6 +162,13 @@ func main() {
logger.Fatalf("Invalid port %q: %v", effectivePort, err)
}
dashboardToken, dashboardSigningKey, newDashTok, dashErr := launcherconfig.EnsureDashboardSecrets()
if dashErr != nil {
logger.Fatalf("Dashboard auth setup failed: %v", dashErr)
}
dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken)
launcherDashboardTokenForClipboard = dashboardToken
// Determine listen address
var addr string
if effectivePublic {
@@ -167,6 +180,21 @@ func main() {
// Initialize Server components
mux := http.NewServeMux()
tokenLogFileAbs := ""
if !enableConsole {
tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile)
}
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
DashboardToken: dashboardToken,
SessionCookie: dashboardSessionCookie,
TokenHelp: api.LauncherAuthTokenHelp{
EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
LogFileAbs: tokenLogFileAbs,
TrayCopyMenu: trayOffersDashboardTokenCopy(),
ConsoleStdout: enableConsole,
},
})
// API Routes (e.g. /api/status)
apiHandler = api.NewHandler(absPath)
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
@@ -183,14 +211,21 @@ func main() {
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
}
dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{
ExpectedCookie: dashboardSessionCookie,
Token: dashboardToken,
}, accessControlledMux)
// Apply middleware stack
handler := middleware.Recoverer(
middleware.Logger(
middleware.JSONContentType(accessControlledMux),
middleware.ReferrerPolicyNoReferrer(
middleware.JSONContentType(dashAuth),
),
),
)
// Print startup banner (only in console mode)
// Print startup banner and token (console mode only).
if enableConsole {
fmt.Print(utils.Banner)
fmt.Println()
@@ -203,6 +238,19 @@ func main() {
}
}
fmt.Println()
if newDashTok {
fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken)
} else if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" {
fmt.Printf(" Dashboard token: %s (from PICOCLAW_LAUNCHER_TOKEN)\n", dashboardToken)
}
fmt.Println()
}
if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" {
logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN")
}
if !enableConsole && newDashTok {
logger.InfoC("web", "Dashboard token (this run): "+dashboardToken)
}
// Log startup info to file
@@ -215,6 +263,11 @@ func main() {
// Share the local URL with the launcher runtime.
serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort)
if dashboardToken != "" {
browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken)
} else {
browserLaunchURL = serverAddr
}
// Auto-open browser will be handled by the launcher runtime.
@@ -0,0 +1,226 @@
package middleware
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"path"
"strings"
"time"
)
// LauncherDashboardCookieName is the HttpOnly cookie set after a successful token login.
const LauncherDashboardCookieName = "picoclaw_launcher_auth"
// launcherDashboardSessionMaxAgeSec is the session cookie lifetime (7 days).
const launcherDashboardSessionMaxAgeSec = 7 * 24 * 3600
const launcherSessionMACLabel = "picoclaw-launcher-v1"
// SessionCookieValue is the expected cookie value for the given signing key and dashboard token.
func SessionCookieValue(signingKey []byte, dashboardToken string) string {
mac := hmac.New(sha256.New, signingKey)
_, _ = mac.Write([]byte(launcherSessionMACLabel))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(dashboardToken))
return hex.EncodeToString(mac.Sum(nil))
}
// LauncherDashboardAuthConfig holds runtime material for dashboard access checks.
type LauncherDashboardAuthConfig struct {
ExpectedCookie string
Token string
// SecureCookie sets the session cookie's Secure flag. If nil, DefaultLauncherDashboardSecureCookie is used.
SecureCookie func(*http.Request) bool
}
// DefaultLauncherDashboardSecureCookie mirrors typical production HTTPS detection (TLS or X-Forwarded-Proto).
func DefaultLauncherDashboardSecureCookie(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard token login.
func SetLauncherDashboardSessionCookie(
w http.ResponseWriter,
r *http.Request,
sessionValue string,
secure func(*http.Request) bool,
) {
if secure == nil {
secure = DefaultLauncherDashboardSecureCookie
}
http.SetCookie(w, &http.Cookie{
Name: LauncherDashboardCookieName,
Value: sessionValue,
Path: "/",
MaxAge: launcherDashboardSessionMaxAgeSec,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure(r),
})
}
// ClearLauncherDashboardSessionCookie clears the dashboard session (e.g. logout).
func ClearLauncherDashboardSessionCookie(w http.ResponseWriter, r *http.Request, secure func(*http.Request) bool) {
if secure == nil {
secure = DefaultLauncherDashboardSecureCookie
}
http.SetCookie(w, &http.Cookie{
Name: LauncherDashboardCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure(r),
Expires: time.Unix(0, 0),
})
}
// LauncherDashboardAuth requires a valid session cookie or Authorization: Bearer <token>
// before calling next. Public paths are login page and /api/auth/* handlers.
func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := canonicalAuthPath(r.URL.Path)
if handled := tryLauncherQueryTokenLogin(w, r, p, cfg); handled {
return
}
if isPublicLauncherDashboardPath(r.Method, p) {
next.ServeHTTP(w, r)
return
}
if validLauncherDashboardAuth(r, cfg) {
next.ServeHTTP(w, r)
return
}
rejectLauncherDashboardAuth(w, r, p)
})
}
// canonicalAuthPath matches path cleaning used for routing decisions so
// prefixes like /assets/../ cannot bypass auth (CVE-class traversal).
// tryLauncherQueryTokenLogin validates ?token= on GET only (non-/api), sets the session
// cookie when correct, and redirects with 303 so the follow-up is a plain GET without side effects.
// Invalid token is rejected like any other unauthenticated browser request.
func tryLauncherQueryTokenLogin(
w http.ResponseWriter,
r *http.Request,
canonicalPath string,
cfg LauncherDashboardAuthConfig,
) bool {
if r.Method != http.MethodGet {
return false
}
if canonicalPath == "/api" || strings.HasPrefix(canonicalPath, "/api/") {
return false
}
qToken := strings.TrimSpace(r.URL.Query().Get("token"))
if qToken == "" {
return false
}
if len(qToken) != len(cfg.Token) || subtle.ConstantTimeCompare([]byte(qToken), []byte(cfg.Token)) != 1 {
rejectLauncherDashboardAuth(w, r, canonicalPath)
return true
}
SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie)
http.Redirect(w, r, redirectAfterQueryTokenLogin(r, canonicalPath), http.StatusSeeOther)
return true
}
func redirectAfterQueryTokenLogin(r *http.Request, canonicalPath string) string {
if canonicalPath == "/launcher-login" {
return "/"
}
q := r.URL.Query()
q.Del("token")
enc := q.Encode()
if enc != "" {
return canonicalPath + "?" + enc
}
return canonicalPath
}
func canonicalAuthPath(raw string) string {
if raw == "" {
return "/"
}
c := path.Clean(raw)
switch c {
case ".", "":
return "/"
default:
if c[0] != '/' {
return "/" + c
}
return c
}
}
func isPublicLauncherDashboardPath(method, p string) bool {
if isPublicLauncherDashboardStatic(method, p) {
return true
}
switch p {
case "/api/auth/login":
return method == http.MethodPost
case "/api/auth/logout":
return method == http.MethodPost
case "/api/auth/status":
return method == http.MethodGet
}
return false
}
// isPublicLauncherDashboardStatic allows the SPA login route and embedded
// frontend assets without a session (GET/HEAD only).
func isPublicLauncherDashboardStatic(method, p string) bool {
if method != http.MethodGet && method != http.MethodHead {
return false
}
if p == "/launcher-login" {
return true
}
if strings.HasPrefix(p, "/assets/") {
return true
}
switch p {
case "/favicon.ico", "/favicon.svg", "/favicon-96x96.png",
"/apple-touch-icon.png", "/site.webmanifest", "/robots.txt":
return true
default:
return false
}
}
func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig) bool {
if c, err := r.Cookie(LauncherDashboardCookieName); err == nil {
if subtle.ConstantTimeCompare([]byte(c.Value), []byte(cfg.ExpectedCookie)) == 1 {
return true
}
}
auth := r.Header.Get("Authorization")
const prefix = "Bearer "
if strings.HasPrefix(auth, prefix) {
token := strings.TrimSpace(auth[len(prefix):])
if len(token) == len(cfg.Token) && subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Token)) == 1 {
return true
}
}
return false
}
func rejectLauncherDashboardAuth(w http.ResponseWriter, r *http.Request, canonicalPath string) {
if strings.HasPrefix(canonicalPath, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
return
}
http.Redirect(w, r, "/launcher-login", http.StatusFound)
}
@@ -0,0 +1,162 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestSessionCookieValue_Deterministic(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
a := SessionCookieValue(key, "tok-a")
b := SessionCookieValue(key, "tok-a")
if a != b || a == "" {
t.Fatalf("SessionCookieValue mismatch or empty: %q vs %q", a, b)
}
c := SessionCookieValue(key, "tok-b")
if c == a {
t.Fatal("SessionCookieValue should differ for different tokens")
}
}
func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
h := LauncherDashboardAuth(cfg, next)
for _, tc := range []struct {
method, path string
want int
}{
{http.MethodGet, "/launcher-login", http.StatusTeapot},
{http.MethodGet, "/assets/index.js", http.StatusTeapot},
{http.MethodPost, "/api/auth/login", http.StatusTeapot},
{http.MethodGet, "/api/auth/status", http.StatusTeapot},
{http.MethodPost, "/api/auth/logout", http.StatusTeapot},
{http.MethodGet, "/api/auth/logout", http.StatusUnauthorized},
{http.MethodGet, "/api/config", http.StatusUnauthorized},
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, nil)
h.ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Fatalf("%s %s: status = %d, want %d", tc.method, tc.path, rec.Code, tc.want)
}
}
}
func TestLauncherDashboardAuth_URLTokenBootstrapGET(t *testing.T) {
const tok = "secret"
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: tok}
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
h := LauncherDashboardAuth(cfg, next)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/?token="+tok, nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("GET /?token=valid: status = %d, want %d", rec.Code, http.StatusSeeOther)
}
if got := rec.Header().Get("Location"); got != "/" {
t.Fatalf("Location = %q, want %q", got, "/")
}
if c := rec.Result().Cookies(); len(c) != 1 || c[0].Name != LauncherDashboardCookieName {
t.Fatalf("expected one session cookie, got %#v", c)
}
rec1b := httptest.NewRecorder()
req1b := httptest.NewRequest(http.MethodGet, "/config?token="+tok+"&keep=1", nil)
h.ServeHTTP(rec1b, req1b)
if rec1b.Code != http.StatusSeeOther {
t.Fatalf("GET /config?token=valid: status = %d", rec1b.Code)
}
if got := rec1b.Header().Get("Location"); got != "/config?keep=1" {
t.Fatalf("Location = %q, want /config?keep=1", got)
}
recBad := httptest.NewRecorder()
reqBad := httptest.NewRequest(http.MethodGet, "/?token=wrong", nil)
h.ServeHTTP(recBad, reqBad)
if recBad.Code != http.StatusFound || recBad.Header().Get("Location") != "/launcher-login" {
t.Fatalf("GET /?token=invalid: code=%d loc=%q", recBad.Code, recBad.Header().Get("Location"))
}
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/api/config?token="+tok, nil)
h.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("GET /api with token query: status = %d, want %d", rec2.Code, http.StatusUnauthorized)
}
rec3 := httptest.NewRecorder()
req3 := httptest.NewRequest(http.MethodGet, "/?token=", nil)
h.ServeHTTP(rec3, req3)
if rec3.Code != http.StatusFound {
t.Fatalf("GET /?token=empty: status = %d, want redirect", rec3.Code)
}
recLogin := httptest.NewRecorder()
reqLogin := httptest.NewRequest(http.MethodGet, "/launcher-login?token="+tok, nil)
h.ServeHTTP(recLogin, reqLogin)
if recLogin.Code != http.StatusSeeOther || recLogin.Header().Get("Location") != "/" {
t.Fatalf("GET /launcher-login?token=valid: code=%d loc=%q", recLogin.Code, recLogin.Header().Get("Location"))
}
}
func TestLauncherDashboardAuth_DotDotCannotBypass(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)
for _, p := range []string{
"/assets/../api/config",
"/launcher-login/../api/config",
"/./api/config",
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, p, nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%q: status = %d, want %d", p, rec.Code, http.StatusUnauthorized)
}
}
}
func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = 0xab
}
token := "dashboard-secret-9"
cookieVal := SessionCookieValue(key, token)
cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal, Token: token}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
h := LauncherDashboardAuth(cfg, next)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal})
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("cookie auth: status = %d", rec.Code)
}
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2.Header.Set("Authorization", "Bearer "+token)
h.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("bearer auth: status = %d", rec2.Code)
}
}
+12
View File
@@ -0,0 +1,12 @@
package middleware
import "net/http"
// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response so sensitive
// query parameters (e.g. ?token= for dashboard bootstrap) are not leaked via the Referer header.
func ReferrerPolicyNoReferrer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
+13
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"fyne.io/systray"
"github.com/atotto/clipboard"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/web/backend/utils"
@@ -23,6 +24,7 @@ func onReady() {
// Create menu items
mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip))
mCopyTok := systray.AddMenuItem(T(MenuCopyToken), T(MenuCopyTokenHint))
mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip))
// Add version info under About menu
@@ -50,6 +52,17 @@ func onReady() {
logger.Errorf("Failed to open browser: %v", err)
}
case <-mCopyTok.ClickedCh:
if launcherDashboardTokenForClipboard == "" {
logger.WarnC("web", "Dashboard token is empty; cannot copy")
continue
}
if err := clipboard.WriteAll(launcherDashboardTokenForClipboard); err != nil {
logger.Errorf("Failed to copy dashboard token: %v", err)
} else {
logger.InfoC("web", "Dashboard token copied to clipboard")
}
case <-mVersion.ClickedCh:
// Version info - do nothing, just shows current version
+5
View File
@@ -0,0 +1,5 @@
//go:build (!darwin && !freebsd) || cgo
package main
func trayOffersDashboardTokenCopy() bool { return true }
+5
View File
@@ -0,0 +1,5 @@
//go:build (darwin || freebsd) && !cgo
package main
func trayOffersDashboardTokenCopy() bool { return false }