mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user