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,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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user