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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user