mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user