fix(nodes): never send stored node tokens to clients (#1281)

Node read endpoints now return a client-safe projection that omits the
stored api_token and exposes a has_token boolean instead, so a node's
long-lived proxy credential is never serialized to a browser or API
token client. The token stays encrypted at rest and is read server-side
only by the components that need it (the remote proxy, the connection
test, and the mesh dialer).

The edit form opens the API Token field blank, and a blank value keeps
the existing credential, so saving an edit without retyping the token no
longer clears it; a non-empty value rotates it. The backend enforces the
same rule defensively.

Node management actions (add, edit, delete, test connection, generate
node token) are gated in the UI to match their server-side permission
checks, so operators no longer see an action the API would reject. The
test-connection route also gains the missing server-side permission and
token-scope guards.

Also validate the x-node-id header and fall back to the default node for
malformed values instead of an obscure 404, and return 400 (not 500)
when deleting the default node.
This commit is contained in:
Anso
2026-06-02 10:26:31 -04:00
committed by GitHub
parent 35a1182890
commit 65a69d9ecc
9 changed files with 273 additions and 40 deletions
@@ -12,7 +12,7 @@ vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn() } }));
import { apiFetch } from '@/lib/api';
const localNode: Node = { id: 1, name: 'Local', type: 'local', api_url: '', api_token: '', compose_dir: '', is_default: true, status: 'online', created_at: 0 };
const localNode: Node = { id: 1, name: 'Local', type: 'local', api_url: '', compose_dir: '', is_default: true, status: 'online', created_at: 0 };
const makeNotif = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 1, level: 'info', message: 'test', timestamp: 1000, is_read: 0, ...overrides,
+21 -19
View File
@@ -38,8 +38,8 @@ export function NodeManager() {
const { isAdmin, can } = useAuth();
const canEditLabels = isPaid && isAdmin;
// Mirror the backend node:manage guard. This top-level flag checks the global
// role only (admin or global node-admin); the per-row Edit/Delete buttons below
// additionally honor scoped per-node grants via can('node:manage', 'node', id).
// role only (admin or global node-admin); the per-row Test/Edit/Delete buttons
// below additionally honor scoped per-node grants via can('node:manage', 'node', id).
// Admins resolve immediately via isAdmin; node-admins once /permissions/me lands.
// Generate-token and reset-anchor below stay admin-only to match their stricter
// backend guards (requireAdmin, and requireAdmin + requirePaid).
@@ -453,23 +453,25 @@ export function NodeManager() {
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => testConnection(node)}
disabled={testing === node.id}
aria-label="Test connection"
>
<Wifi className={`w-4 h-4 ${testing === node.id ? 'animate-pulse' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent>Test Connection</TooltipContent>
</Tooltip>
</TooltipProvider>
{canManageThis && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => testConnection(node)}
disabled={testing === node.id}
aria-label="Test connection"
>
<Wifi className={`w-4 h-4 ${testing === node.id ? 'animate-pulse' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent>Test Connection</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{canManageThis && (
<TooltipProvider>
@@ -138,9 +138,14 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
const handleEdit = async () => {
if (editingNodeId === null) return;
try {
// A blank token means "keep the existing credential": the stored token is
// never sent to the browser, so an untouched field must not overwrite it.
// Only a non-empty value rotates the token on the backend.
const { api_token, ...rest } = formData;
const payload = api_token.trim() ? formData : rest;
const res = await apiFetch(`/nodes/${editingNodeId}`, {
method: 'PUT',
body: JSON.stringify(formData),
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json();
@@ -222,18 +227,21 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
}, []);
const openEdit = useCallback((node: Node) => {
// The stored api_token is never returned to the browser, so the token field
// always opens blank. A blank value on save keeps the existing credential;
// typing a new value rotates it.
setFormData({
name: node.name,
type: node.type,
mode: (node.mode === 'pilot_agent' ? 'pilot_agent' : 'proxy'),
api_url: node.api_url || '',
api_token: node.api_token || '',
api_token: '',
compose_dir: node.compose_dir,
is_default: node.is_default,
});
setOriginalEditValues({
api_url: node.api_url || '',
api_token: node.api_token || '',
api_token: '',
});
setEditingNodeId(node.id);
setEditOpen(true);
@@ -244,7 +252,7 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
setDeleteOpen(true);
}, []);
const renderFormFields = () => (
const renderFormFields = (isEdit: boolean) => (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="node-name">Name</Label>
@@ -331,12 +339,18 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
<Input
id="node-api-token"
type="password"
placeholder="Paste token from remote Sencho → Settings → Nodes → Generate Token"
placeholder={isEdit
? 'Leave blank to keep the current token'
: 'Paste token from remote Sencho → Settings → Nodes → Generate Token'}
value={formData.api_token}
onChange={(e) => setFormData({ ...formData, api_token: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Generate this token on the <strong>remote</strong> Sencho instance using the "Generate Node Token" button in its Settings Nodes panel.
{isEdit ? (
'Leave blank to keep the existing token. Paste a new one only to rotate it.'
) : (
<>Generate this token on the <strong>remote</strong> Sencho instance using the "Generate Node Token" button in its Settings Nodes panel.</>
)}
</p>
</div>
</>
@@ -372,7 +386,7 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
title={formData.type === 'local' ? 'Add local node' : 'Add remote node'}
description="Register a Sencho node so you can manage it from this console."
/>
<ModalBody>{renderFormFields()}</ModalBody>
<ModalBody>{renderFormFields(false)}</ModalBody>
<ModalFooter
secondary={
<Button variant="outline" size="sm" onClick={() => setCreateOpen(false)}>Cancel</Button>
@@ -405,7 +419,7 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
>
<ModalHeader kicker="NODES · EDIT" title="Edit node" description="Update the connection details for this node." />
<ModalBody>
{renderFormFields()}
{renderFormFields(true)}
{formData.type === 'remote' && formData.mode === 'pilot_agent' && editingNodeId !== null && (
<div className="rounded-md border border-card-border bg-card/50 p-3 space-y-2">
<p className="text-xs text-muted-foreground">