Independent reviews of server/agent/dashboard by Gemini 3.1 Pro and Grok 4.3, each reading source via its own read_file tool, plus a Claude synthesis and a phased remediation plan. Top convergent finding: secrets/tokens in WebSocket URL query strings across agent + dashboard. See SYNTHESIS-three-way.md and REMEDIATION-PLAN.md.
15 KiB
GuruConnect dashboard review — grok — 2026-06-05
Independent code review: GuruConnect dashboard (TypeScript/React operator console)
All 66 files were read via read_file (absolute paths exactly as specified). Review is strictly limited to the provided files. No modifications were made. Backend (Rust relay, protobuf wire protocol, DB, JWT minting/claims, session manager, support-code redemption, SQL, agent auth) is out of scope; findings are restricted to what is observable or implied by the dashboard code + its API client.
Findings are prioritized (CRITICAL → HIGH → MEDIUM → LOW). Format per request: [SEVERITY: X], file:line, problem, concrete fix. Focus areas were applied literally. Only concrete, evidenced issues are listed.
CRITICAL
[SEVERITY: CRITICAL], dashboard/src/features/sessions/JoinSessionModal.tsx:29-36
The short-lived viewer token (which the relay uses to grant control or view_only access to a live remote session, binding to a specific session_id) is placed directly into the query string when building the WS viewer URL:
const qs = new URLSearchParams({
session_id: token.session_id,
viewer_name: viewerName,
token: token.token, // !!!
});
return `${wsProto}//${window.location.host}/ws/viewer?${qs.toString()}`;
(See: buildViewerUrl, the effect that calls mintViewerToken, the "Relay viewer URL" FieldReveal copy target, JoinSessionModal:108-112 comments, sessionsApi.mintViewerToken, and the fact that the relay reads ViewerParams from query per the comment.)
Problem: Query-string tokens appear in browser history, server access logs (relay + any reverse proxy), proxy logs, copy-paste artifacts, and can be bookmarked/shared. This is the capability that actually attaches a viewer to an agent's screen/stream. Even with short expires_in_secs, a leaked token for a live attended or managed session enables immediate session viewing or takeover. The client has no additional binding (e.g., origin, viewer IP) visible here.
Fix: Move the token out of the query string. Preferred: pass via Authorization: Bearer header on the WS upgrade or a subprotocol (Sec-WebSocket-Protocol). If the current relay wire format requires query params for the viewer plane, (a) make the minted token one-time or single-use after first successful attach, (b) bind it to the caller's IP or a very short TTL (<< current), and (c) document the exposure + require operators to treat the entire URL as a single-use secret. At minimum, stop surfacing the full URL as a casually copyable field.
[SEVERITY: CRITICAL], dashboard/src/auth/AuthProvider.tsx:7 (STORAGE_KEY), 16 (sessionStorage.getItem + tokenRef), 66-73 (login), 42-64 (restore), 75-84 (logout), combined with client.ts:86-87 and all privileged API calls
The primary dashboard JWT (used for GET /me, listing machines/sessions/codes, POST /codes, POST /sessions/:id/viewer-token (the control/view mint), admin user/machine operations, etc.) has no client-visible expiration handling, refresh, max lifetime, or idle timeout. It lives in sessionStorage (survives reloads within the tab lifetime) + an in-memory ref and is blindly attached as Bearer on every request.
Problem: Compromise of the operator workstation (XSS in the console, malware, devtools exfil, physical access while tab is open, or shoulder-surf of a token during debugging) yields long-lived privileged access. Because mintViewerToken re-uses the same bearer, the attacker can also obtain per-session control/view tokens for any machine/session the account can see. Logout is best-effort; 401 is the only forced teardown. No exp parsing or proactive re-auth is present.
Fix: Server should issue short-lived access tokens (or rotate the main token) + refresh tokens (or a refresh endpoint that requires the old token + rotation). Client must: (a) parse and respect exp (or an explicit expires_in) from LoginResponse, (b) proactively refresh or force logout before expiry, (c) add an idle/activity timeout that clears the session after N minutes of inactivity, and (d) consider binding the token to the browser tab lifetime more strictly. Treat the current long-lived bearer as equivalent to persistent remote access creds.
HIGH
[SEVERITY: HIGH], dashboard/src/features/codes/api/codes.ts:37-38 (cancelCode) + SupportCodesPage.tsx:21-24 (canCancel) + 114-136 (actions) + GenerateCodeModal + hooks
Support code value (the XXX-XXX-XXX string read aloud to the end user, the single-use bearer for attended session start) is used as a path segment: `/api/codes/${encodeURIComponent(code)}/cancel`. The client also renders code.session_id, client_machine, client_name, connected_at, and consent state directly from the polled list response. No additional client-side binding verification or proof-of-possession step exists before offering cancel or trusting "connected" state.
Problem: An attacker with a dashboard JWT (see CRITICAL auth finding) can enumerate active codes via listCodes and cancel pending/connected ones (DoS on support workflows) or observe which codes have bound to which sessions/machines. The code-to-session binding and single-use guard are entirely server-side; the dashboard is a pure reflection of whatever the /codes and /sessions responses claim. A server-side race or mis-binding between code redemption and session creation would be invisible to (and unmitigated by) the client.
Fix: (Defense-in-depth on client) Treat the human code as a high-value secret in the UI: minimize how long it is rendered in clear, add explicit "this code is now bound to session X for machine Y — confirm before cancel" extra confirmation when status==="connected", and surface the exact session_id + consent_state with a warning on the cancel dialog. Server must remain authoritative for binding and single-use enforcement; client should not be the only thing preventing reuse.
[SEVERITY: HIGH], dashboard/src/features/machines/MachinesPage.tsx:56 (isAdmin), 228-250 (conditional Keys/Remove buttons), 380-396 (modals), UsersPage.tsx:42+161 (self checks), EditUserModal.tsx:92-120 (lockSelfDemotion + isSelf guards), AdminRoute.tsx:15-56, AuthContext.tsx:10+93 (isAdmin = role === "admin"), and equivalent hasPermission checks throughout
All privileged UI affordances (bulk remove, per-agent key mint/revoke, user create/edit/delete, admin routes) are gated exclusively by client-side isAdmin (exact string compare on user.role) or hasPermission(p) (user?.permissions.includes(p) ?? false). These derive the canControl/canView passed to JoinSessionModal and the selection rails.
Problem: These are only UI hints. A desync (role string casing, permission array containing non-canonical values, future role like "super-admin", or a bug in the AuthProvider memo) can show/hide actions incorrectly. Self-lockout guards (disable own account, demote self from admin) are client-only in the modal; the server comment in the code acknowledges it does not currently block self-demotion. While the server re-enforces (AdminUser extractor, 403s, self-delete 400), the client state is what the operator sees and what drives which buttons are even rendered.
Fix: Keep the client gates but make them derive from a single, narrow, well-commented can* helper that exactly mirrors the documented server rules (or fetch effective capabilities from /me or a dedicated endpoint). Add explicit client-side rejection + toast for the self-demotion/disable cases even if the server would later 4xx. Widen the Role | string / Permission | string handling only after logging the unexpected value.
MEDIUM
[SEVERITY: MEDIUM], dashboard/src/api/client.ts:65-82 (extractError), 108-113 (401 path), 118-127 (success body handling) + callers that surface raw messages
Error normalization handles two envelopes but falls back to raw res.text() (trimmed if <300 chars) for non-JSON and for certain machines routes (plain &'static str). Mutation error toasts (e.g. DeleteMachineDialog, BulkRemove, users hooks) directly render err.message.
Problem: Server error bodies can leak internal details, hostnames, IPs (from events), or implementation strings into the operator UI and any logs/toasts that capture them. No redaction or classification of error text is performed before display. 401 after the handler still throws, which is mostly fine but means some error paths can double-surface.
Fix: In extractError (or a post-processing step in the toast surfaces), treat unknown/plain-text errors as opaque. Prefix or categorize them ("Server error: ...") rather than dumping the body verbatim. Review the Rust error responses for machines/users/sessions/codes to ensure they never emit sensitive fields on failure paths.
[SEVERITY: MEDIUM], dashboard/src/features/machines/MachinesPage.tsx:372 (rowKey={(m) => m.id}), 66-88 (selected Set keyed by agent_id + reconciliation), 78-88 (useEffect that reconciles against live agent_ids), hooks.ts:12-18, api/machines.ts:18-19 (paths use agent_id), and Machine type (both id and agent_id present)
React table keys use m.id while selection state, bulk operations, detail drawers, key modals, remove flows, filters, and all API paths consistently use agent_id. Reconciliation logic keys off agent_id.
Problem: If id (DB/synthetic) and agent_id (agent-reported stable identity) can ever diverge in lifetime or uniqueness (soft-delete + re-enroll, purge paths, historical rows), React list reconciliation, selection survival across polls, and "remove selected" correctness become fragile. The bulk bar and dialogs already have careful visible-set logic; a key mismatch adds another surface for state skew.
Fix: Standardize on the stable business key (agent_id) for rowKey, React keys, and selection. Keep id only for display or history joins if the server distinguishes them. Add a comment in types.ts and the page explaining the two identifiers.
[SEVERITY: MEDIUM], dashboard/src/features/users/hooks.ts:58-85 (useUpdateUser — two sequential awaits), 82 (onSettled always invalidates) + EditUserModal:122-134 (sendPermissions logic) + types.ts:99-104 (UpdateUserRequest)
Update is split (core fields then optional permissions PUT). On permissions failure it throws a synthetic Error; onSettled (not onSuccess) invalidates.
Problem: Partial success window exists (role/enabled changed, permissions not yet or failed). The error message rewrite is good, but an operator who closes the modal or navigates away during the window sees inconsistent state until the next refetch. No client-side transaction or "permissions pending" indicator.
Fix: Either make the server accept a single combined update, or surface an explicit "core saved; retry permissions" state in the modal (keep it open, highlight the PermissionsField) instead of fully rejecting the mutation. The current onSettled + rewritten error is already better than most, but the UX for the partial case is still a toast + stale-looking row until poll.
LOW / Architecture & Maintainability
[SEVERITY: LOW], dashboard/src/api/stubs.ts:1-18 + api/index.ts:6 (export * as stubsApi)
Stubs for sessions/users that return unknown[] are still exported under stubsApi (with comments "Pass 1", "do NOT build UI against these yet"). Real implementations live in sessions.ts / users.ts and are imported directly by features.
Problem: Dead/exported-but-unused surface that can mislead readers or cause accidental import of the stub versions. Minor namespace pollution.
Fix: Delete or clearly mark as @deprecated / internal scaffolding and remove from the barrel export. (Trivial.)
[SEVERITY: LOW], dashboard/src/features/*/hooks.ts (multiple): refetchInterval + staleTime hardcoded (machines 20s/10s, sessions 8s/4s, codes 7s/3.5s, health 15s), no enabled: !document.hidden or jitter, no error backoff
Polling is aggressive and constant.
Problem: Unnecessary load on the relay when the tab is backgrounded or the operator is idle on another page. No adaptive behavior.
Fix: Add a small useVisibility or document.visibilityState guard to pause refetchInterval when hidden. Consider light jitter or a single shared "activity" signal. Keep the intervals tight for consent/watch use cases but make them configurable or at least documented.
[SEVERITY: LOW], dashboard/src/api/types.ts (entire file, especially ROLES/PERMISSIONS/ROLE_DEFAULT_PERMISSIONS, all the | string widenings, hand-maintained comment at top) + consumers that do exact string compares or .includes
Types are manually kept in sync with server/src/api/*.rs. Many fields are widened to tolerate drift.
Problem: Classic source-of-truth split. A new role/permission, a renamed field, or a new CodeStatus will either cause runtime surprises or require coordinated changes across Rust + this file. The defensive | string + filter-to-canonical in EditUserModal etc. mitigate but do not eliminate the maintenance tax.
Fix: (Long-term) Generate the TS types from the Rust API (OpenAPI, prost, or a small schema export). In the interim, add a test or build step that diffs the TypeScript literals against a checked-in snapshot from the server.
Additional minor notes (not elevated to findings):
useClipboardexecCommand fallback is deprecated but acceptable for an internal tool in non-secure contexts.- No
dangerouslySetInnerHTML; React text is used safely. - Dialog stack + focus trap + inert + restore in Modal/Drawer is correctly implemented and shared.
- Password generation (Web Crypto + rejection sampling) + one-time copy + wipe-on-close is done well.
encodeURIComponentis consistently used on all path segments (agent_id, code, session id, user id, key id).- StrictMode double-mint guards in GenerateCodeModal and JoinSessionModal are present and correct.
Overall Assessment
The dashboard is a clean, well-factored React + react-query SPA with strong separation between API client, typed feature hooks, and presentational components. UI/UX details around one-time secrets (codes, keys, passwords, viewer tokens), consent state, self-lockout, stacked dialogs, and partial-failure reconciliation are thoughtful and above average for an internal operator tool. The posture "client renders what the server says; server is the authority" is consistently followed (AdminRoute, permission checks, etc. are treated as hints + defense-in-depth).
The code is skeptical of its own state in the right places (reconciliation effects, mintedFor refs, onSettled invalidation, | string widenings) but trusts the authenticated API responses for the security-critical bindings (code → session, viewer token access mode, consent_state, machine ownership).
Single most important thing to fix: The CRITICAL placement of viewer tokens (the actual remote-control capability tokens) into query strings in JoinSessionModal.buildViewerUrl. This is the direct bridge from the operator console to live agent sessions. Everything else (auth lifetime, client-side gates, support-code exposure) is important but secondary to preventing easy leakage of tokens that let an observer or attacker attach to a victim's desktop/session. Fix the wire format or the mint+display path for these tokens first.