fix(pairing): fix browser approval stuck + security hardening

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
This commit is contained in:
viettranx committed 2026-03-16 20:09:44 +07:00
1 parent 89303f5624
commit b0bd4d6198
7 files changed
+50 -9

No files matched your search

+5
View File
@@ -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))
+5
View File
@@ -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:") {
+19
View File
@@ -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}
+3
View File
@@ -7,6 +7,7 @@ export class HttpClient {
private baseUrl: string,
private getToken: () => string,
private getUserId: () => string,
private getSenderID: () => string = () => "",
) {}
async get<T>(path: string, params?: Record<string, string>): Promise<T> {
@@ -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;
}
@@ -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;
}, []);
@@ -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 <Navigate to={ROUTES.LOGIN} state={{ from: location }} replace />;
}
+7 -6
View File
@@ -13,7 +13,7 @@ export function PairingForm({ onApproved }: PairingFormProps) {
const { t } = useTranslation("login");
const [userId, setUserId] = useState("");
const [code, setCode] = useState<string | null>(null);
const [senderID, setSenderID] = useState<string | null>(null);
const senderIDRef = useRef<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<PairingStatus>("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");
}