fix(security): harden and refactor system.run command resolution

This commit is contained in:
Peter Steinberger
2026-02-21 11:49:13 +01:00
parent 5cc631cc9c
commit 6007941f04
6 changed files with 679 additions and 368 deletions

View File

@@ -0,0 +1,85 @@
import { describe, expect, test } from "vitest";
import type { ExecApprovalRecord } from "./exec-approval-manager.js";
import { sanitizeSystemRunParamsForForwarding } from "./node-invoke-system-run-approval.js";
describe("sanitizeSystemRunParamsForForwarding", () => {
const now = Date.now();
const client = {
connId: "conn-1",
connect: {
scopes: ["operator.write", "operator.approvals"],
device: { id: "dev-1" },
client: { id: "cli-1" },
},
};
function makeRecord(command: string): ExecApprovalRecord {
return {
id: "approval-1",
request: {
host: "node",
command,
cwd: null,
agentId: null,
sessionKey: null,
},
createdAtMs: now - 1_000,
expiresAtMs: now + 60_000,
requestedByConnId: "conn-1",
requestedByDeviceId: "dev-1",
requestedByClientId: "cli-1",
resolvedAtMs: now - 500,
decision: "allow-once",
resolvedBy: "operator",
};
}
function manager(record: ReturnType<typeof makeRecord>) {
return {
getSnapshot: () => record,
};
}
test("rejects cmd.exe /c trailing-arg mismatch against rawCommand", () => {
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {
command: ["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"],
rawCommand: "echo",
runId: "approval-1",
approved: true,
approvalDecision: "allow-once",
},
client,
execApprovalManager: manager(makeRecord("echo")),
nowMs: now,
});
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("unreachable");
}
expect(result.message).toContain("rawCommand does not match command");
expect(result.details?.code).toBe("RAW_COMMAND_MISMATCH");
});
test("accepts matching cmd.exe /c command text for approval binding", () => {
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {
command: ["cmd.exe", "/d", "/s", "/c", "echo", "SAFE&&whoami"],
rawCommand: "echo SAFE&&whoami",
runId: "approval-1",
approved: true,
approvalDecision: "allow-once",
},
client,
execApprovalManager: manager(makeRecord("echo SAFE&&whoami")),
nowMs: now,
});
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error("unreachable");
}
const params = result.params as Record<string, unknown>;
expect(params.approved).toBe(true);
expect(params.approvalDecision).toBe("allow-once");
});
});

View File

@@ -1,9 +1,5 @@
import {
formatExecCommand,
validateSystemRunCommandConsistency,
} from "../infra/system-run-command.js";
import type { ExecApprovalManager, ExecApprovalRecord } from "./exec-approval-manager.js";
import type { GatewayClient } from "./server-methods/types.js";
import { resolveSystemRunCommand } from "../infra/system-run-command.js";
import type { ExecApprovalRecord } from "./exec-approval-manager.js";
type SystemRunParamsLike = {
command?: unknown;
@@ -19,6 +15,18 @@ type SystemRunParamsLike = {
runId?: unknown;
};
type ApprovalLookup = {
getSnapshot: (recordId: string) => ExecApprovalRecord | null;
};
type ApprovalClient = {
connId?: string | null;
connect?: {
scopes?: unknown;
device?: { id?: string | null } | null;
} | null;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
@@ -39,31 +47,20 @@ function normalizeApprovalDecision(value: unknown): "allow-once" | "allow-always
return s === "allow-once" || s === "allow-always" ? s : null;
}
function clientHasApprovals(client: GatewayClient | null): boolean {
function clientHasApprovals(client: ApprovalClient | null): boolean {
const scopes = Array.isArray(client?.connect?.scopes) ? client?.connect?.scopes : [];
return scopes.includes("operator.admin") || scopes.includes("operator.approvals");
}
function getCmdText(params: SystemRunParamsLike): string {
const raw = normalizeString(params.rawCommand);
if (raw) {
return raw;
}
if (Array.isArray(params.command)) {
const parts = params.command.map((v) => String(v));
if (parts.length > 0) {
return formatExecCommand(parts);
}
}
return "";
}
function approvalMatchesRequest(params: SystemRunParamsLike, record: ExecApprovalRecord): boolean {
function approvalMatchesRequest(
cmdText: string,
params: SystemRunParamsLike,
record: ExecApprovalRecord,
): boolean {
if (record.request.host !== "node") {
return false;
}
const cmdText = getCmdText(params);
if (!cmdText || record.request.command !== cmdText) {
return false;
}
@@ -118,8 +115,8 @@ function pickSystemRunParams(raw: Record<string, unknown>): Record<string, unkno
*/
export function sanitizeSystemRunParamsForForwarding(opts: {
rawParams: unknown;
client: GatewayClient | null;
execApprovalManager?: ExecApprovalManager;
client: ApprovalClient | null;
execApprovalManager?: ApprovalLookup;
nowMs?: number;
}):
| { ok: true; params: unknown }
@@ -130,25 +127,18 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
}
const p = obj as SystemRunParamsLike;
const argv = Array.isArray(p.command) ? p.command.map((v) => String(v)) : [];
const raw = normalizeString(p.rawCommand);
if (raw) {
if (!Array.isArray(p.command) || argv.length === 0) {
return {
ok: false,
message: "rawCommand requires params.command",
details: { code: "MISSING_COMMAND" },
};
}
const validation = validateSystemRunCommandConsistency({ argv, rawCommand: raw });
if (!validation.ok) {
return {
ok: false,
message: validation.message,
details: validation.details ?? { code: "RAW_COMMAND_MISMATCH" },
};
}
const cmdTextResolution = resolveSystemRunCommand({
command: p.command,
rawCommand: p.rawCommand,
});
if (!cmdTextResolution.ok) {
return {
ok: false,
message: cmdTextResolution.message,
details: cmdTextResolution.details,
};
}
const cmdText = cmdTextResolution.cmdText;
const approved = p.approved === true;
const requestedDecision = normalizeApprovalDecision(p.approvalDecision);
@@ -221,7 +211,7 @@ export function sanitizeSystemRunParamsForForwarding(opts: {
};
}
if (!approvalMatchesRequest(p, snapshot)) {
if (!approvalMatchesRequest(cmdText, p, snapshot)) {
return {
ok: false,
message: "approval id does not match request",