fix: gate cross-node HTTP and stop-by-label on remote RBAC capability (#1509)

An older remote node ignores the forwarded actor-role header (running proxied
requests as admin) and ignores the stop-by-label stack allowlist (stopping
every label-matched stack). The control could neither detect nor prevent this
on a mixed-version fleet.

Instances now advertise a cross-node-rbac capability, and the control refuses
to act when a remote lacks it:

- HTTP proxy: a non-admin user's request is not forwarded to a remote that does
  not advertise the capability (fails closed when it cannot be determined).
  Admins are unaffected.
- Stop-by-label: a real stop bound to a confirmed stack set is not sent to a
  remote lacking the capability; the node is reported as needing an upgrade. As
  defense in depth, a node whose results name stacks outside the confirmed set
  is failed rather than rendered as a clean stop.

Separately, the stop's lock-contention path now reports every confirmed stack
as a contention failure (including one that lost its label), so a confirmed
stack is never silently dropped and the result is never empty.
This commit is contained in:
Anso
2026-06-28 18:28:47 -04:00
committed by GitHub
parent ef164f0e5b
commit 997a6bb79a
11 changed files with 365 additions and 1 deletions
+25
View File
@@ -20,6 +20,7 @@ import { requirePaid, requireAdmin, requireNodeProxy } from '../middleware/tierG
import { requirePermission } from '../middleware/permissions';
import { scheduleLocalUpdate } from './license';
import { runPolicyGate, assertPolicyGateAllows, buildPolicyGateOptions } from '../helpers/policyGate';
import { remoteSupportsCrossNodeRbac } from '../helpers/remoteCapabilities';
import { captureLocalNodeFiles, captureRemoteNodeFiles, buildSnapshotDocumentation, pickDossierFields, dossierHasContent, type SnapshotNodeData, type SnapshotDocumentation } from '../utils/snapshot-capture';
import { getLatestVersion, getLatestRelease } from '../utils/version-check';
import { isValidStackName } from '../utils/validation';
@@ -1464,6 +1465,22 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
if (!target) {
return { nodeId: node.id, nodeName: node.name, reachable: false, matched: false, stackResults: [], error: formatNoTargetError(node) };
}
// A real stop bound to a confirmed stack set must only go to a remote that
// honors the allowlist (cross-node-rbac). An older remote ignores
// `stackNames` and would stop every label-matched stack, including ones
// labelled after the preview. Refuse to send and report the node as
// needing an upgrade instead. Dry runs carry no allowlist and only
// preview, so they are safe to send to any version.
if (!isDryRun && allowedStacks) {
const supported = await remoteSupportsCrossNodeRbac(node.id);
if (!supported) {
return {
nodeId: node.id, nodeName: node.name, reachable: false, matched: false,
stackResults: failAllStacks([...allowedStacks], 'Node must be upgraded to honor an exact-stack stop'),
error: 'Node is running a version that cannot limit the stop to the confirmed stacks; upgrade it and retry.',
};
}
}
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`;
@@ -1490,6 +1507,14 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
if (!isLabelLocalStopResponse(remote)) {
return { nodeId: node.id, nodeName: node.name, reachable: false, matched: false, stackResults: [], error: 'Remote returned a malformed response' };
}
// Defense in depth behind the capability gate: if a confirmed allowlist
// was sent, the remote must report only stacks from it. A result naming
// a stack outside the confirmed set means the remote ignored the
// allowlist (an over-broad stop), so fail the node rather than render
// the extra stops as a clean result.
if (allowedStacks && !remote.results.every(r => allowedStacks.has(r.stackName))) {
return { nodeId: node.id, nodeName: node.name, reachable: false, matched: false, stackResults: [], error: 'Remote stopped stacks outside the confirmed set' };
}
return {
nodeId: node.id, nodeName: node.name, reachable: true,
matched: remote.matched,