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
+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"}