From b0bd4d61982f0822ce085810ab52f0d353d8b981 Mon Sep 17 00:00:00 2001 From: viettranx Date: Mon, 16 Mar 2026 20:09:44 +0700 Subject: [PATCH] fix(pairing): fix browser approval stuck + security hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash-merge PR #225 with security fixes: - Fix browser pairing stuck on "Waiting for approval" (stale closure: useState → useRef for senderID in pairing-form) - Fix auto-kick after pairing (RequireAuth now accepts senderID, onAuthFailure skips logout for paired browser sessions) - Allow browser-paired users to access HTTP APIs via X-GoClaw-Sender-Id header with fail-closed IsPaired check - Remove ad-hoc IsInternalOrBrowser(), use channels.IsInternalChannel() - Log failed HTTP pairing auth attempts for security monitoring - Pass senderID to HttpClient for authenticated HTTP requests --- cmd/gateway.go | 5 +++++ cmd/gateway_channels_setup.go | 5 +++++ internal/http/auth.go | 19 +++++++++++++++++++ ui/web/src/api/http-client.ts | 3 +++ .../src/components/providers/ws-provider.tsx | 11 +++++++++-- ui/web/src/components/shared/require-auth.tsx | 3 ++- ui/web/src/pages/login/pairing-form.tsx | 13 +++++++------ 7 files changed, 50 insertions(+), 9 deletions(-) diff --git a/cmd/gateway.go b/cmd/gateway.go index 9e95800c..e5374aed 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -354,6 +354,11 @@ func runGateway() { httpapi.InitAPIKeyCache(pgStores.APIKeys, msgBus) } + // Allow browser-paired users to access HTTP APIs + if pgStores.Pairing != nil { + httpapi.InitPairingAuth(pgStores.Pairing) + } + // Memory management API (wired directly, only needs MemoryStore + token) if pgStores != nil && pgStores.Memory != nil { server.SetMemoryHandler(httpapi.NewMemoryHandler(pgStores.Memory, cfg.Gateway.Token)) diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index 2ff838bc..e2649f05 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -150,6 +150,11 @@ func wireChannelEventSubscribers( // Wire pairing approval notification → channel (matching TS notifyPairingApproved). botName := cfg.ResolveDisplayName("default") pairingMethods.SetOnApprove(func(ctx context.Context, channel, chatID, senderID string) { + // Browser/internal channels use WebSocket — UI polls approval status directly. + if channels.IsInternalChannel(channel) { + slog.Debug("pairing approved for internal channel, skipping notification", "channel", channel) + return + } msg := fmt.Sprintf("✅ %s access approved. Send a message to start chatting.", botName) // Group pairings need group_id metadata so channels (e.g. Zalo) route to group API. if strings.HasPrefix(senderID, "group:") { diff --git a/internal/http/auth.go b/internal/http/auth.go index 5c80d07a..e7d84d3e 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -76,6 +76,7 @@ func extractAgentID(r *http.Request, model string) string { // --- Package-level API key cache for shared auth --- var pkgAPIKeyCache *apiKeyCache +var pkgPairingStore store.PairingStore // InitAPIKeyCache initializes the shared API key cache with TTL and pubsub invalidation. // Must be called once during server startup before handling requests. @@ -90,6 +91,12 @@ func InitAPIKeyCache(s store.APIKeyStore, mb *bus.MessageBus) { } } +// InitPairingAuth sets the pairing store for HTTP auth. +// Allows browser-paired users to access HTTP APIs via X-GoClaw-Sender-Id header. +func InitPairingAuth(ps store.PairingStore) { + pkgPairingStore = ps +} + // ResolveAPIKey checks if the bearer token is a valid API key using the shared cache. // Returns the key data and derived role, or nil if not found/expired/revoked. func ResolveAPIKey(ctx context.Context, token string) (*store.APIKeyData, permissions.Role) { @@ -123,6 +130,18 @@ func resolveAuthBearer(r *http.Request, gatewayToken, bearer string) authResult if _, role := ResolveAPIKey(r.Context(), bearer); role != "" { return authResult{Role: role, Authenticated: true} } + // Browser pairing → operator (via X-GoClaw-Sender-Id header) + if senderID := r.Header.Get("X-GoClaw-Sender-Id"); senderID != "" && pkgPairingStore != nil { + paired, err := pkgPairingStore.IsPaired(senderID, "browser") + if err == nil && paired { + return authResult{Role: permissions.RoleOperator, Authenticated: true} + } + if err != nil { + slog.Warn("security.http_pairing_check_failed", "sender_id", senderID, "error", err) + } else { + slog.Warn("security.http_pairing_auth_failed", "sender_id", senderID, "ip", r.RemoteAddr) + } + } // No auth configured → operator (backward compat) if gatewayToken == "" { return authResult{Role: permissions.RoleOperator, Authenticated: true} diff --git a/ui/web/src/api/http-client.ts b/ui/web/src/api/http-client.ts index dc5a7785..a6018859 100644 --- a/ui/web/src/api/http-client.ts +++ b/ui/web/src/api/http-client.ts @@ -7,6 +7,7 @@ export class HttpClient { private baseUrl: string, private getToken: () => string, private getUserId: () => string, + private getSenderID: () => string = () => "", ) {} async get(path: string, params?: Record): Promise { @@ -108,6 +109,8 @@ export class HttpClient { if (token) h["Authorization"] = `Bearer ${token}`; const userId = this.getUserId(); if (userId) h["X-GoClaw-User-Id"] = userId; + const senderID = this.getSenderID(); + if (senderID) h["X-GoClaw-Sender-Id"] = senderID; return h; } diff --git a/ui/web/src/components/providers/ws-provider.tsx b/ui/web/src/components/providers/ws-provider.tsx index e92cb524..bc53ae98 100644 --- a/ui/web/src/components/providers/ws-provider.tsx +++ b/ui/web/src/components/providers/ws-provider.tsx @@ -31,7 +31,10 @@ export function WsProvider({ children }: { children: React.ReactNode }) { }, ); wsRef.current.onAuthFailure = () => { - useAuthStore.getState().logout(); + // Don't logout if authenticated via browser pairing (no token) + const state = useAuthStore.getState(); + if (state.senderID && !state.token) return; + state.logout(); }; } const ws = wsRef.current; @@ -41,9 +44,13 @@ export function WsProvider({ children }: { children: React.ReactNode }) { "", () => useAuthStore.getState().token, () => useAuthStore.getState().userId, + () => useAuthStore.getState().senderID, ); client.onAuthFailure = () => { - useAuthStore.getState().logout(); + // Don't logout if authenticated via browser pairing (no token) + const state = useAuthStore.getState(); + if (state.senderID && !state.token) return; + state.logout(); }; return client; }, []); diff --git a/ui/web/src/components/shared/require-auth.tsx b/ui/web/src/components/shared/require-auth.tsx index 89f8ce80..38c2d2f1 100644 --- a/ui/web/src/components/shared/require-auth.tsx +++ b/ui/web/src/components/shared/require-auth.tsx @@ -5,9 +5,10 @@ import { ROUTES } from "@/lib/constants"; export function RequireAuth({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token); const userId = useAuthStore((s) => s.userId); + const senderID = useAuthStore((s) => s.senderID); const location = useLocation(); - if (!token || !userId) { + if ((!token && !senderID) || !userId) { return ; } diff --git a/ui/web/src/pages/login/pairing-form.tsx b/ui/web/src/pages/login/pairing-form.tsx index d33e13ea..2223f62d 100644 --- a/ui/web/src/pages/login/pairing-form.tsx +++ b/ui/web/src/pages/login/pairing-form.tsx @@ -13,7 +13,7 @@ export function PairingForm({ onApproved }: PairingFormProps) { const { t } = useTranslation("login"); const [userId, setUserId] = useState(""); const [code, setCode] = useState(null); - const [senderID, setSenderID] = useState(null); + const senderIDRef = useRef(null); const [error, setError] = useState(null); const [status, setStatus] = useState("idle"); @@ -87,14 +87,15 @@ export function PairingForm({ onApproved }: PairingFormProps) { payload.sender_id ) { setCode(payload.pairing_code as string); - setSenderID(payload.sender_id as string); + const sid = payload.sender_id as string; + senderIDRef.current = sid; setStatus("pending"); - startPolling(ws, payload.sender_id as string); + startPolling(ws, sid); return; } - if (payload.status === "approved" && senderID) { - handleApproved(senderID); + if (payload.status === "approved" && senderIDRef.current) { + handleApproved(senderIDRef.current); } }; @@ -133,7 +134,7 @@ export function PairingForm({ onApproved }: PairingFormProps) { if (pollRef.current) clearTimeout(pollRef.current); if (wsRef.current) wsRef.current.close(); setCode(null); - setSenderID(null); + senderIDRef.current = null; setStatus("idle"); }