mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 09:00:22 +00:00
fix: enforce pairing approval scopes
This commit is contained in:
committed by
Peter Steinberger
parent
5d0562badf
commit
353d93613c
@@ -160,7 +160,12 @@ describe("device-pair /pair qr", () => {
|
||||
|
||||
it("returns an inline QR image for webchat surfaces", async () => {
|
||||
const command = registerPairCommand();
|
||||
const result = await command.handler(createCommandContext({ channel: "webchat" }));
|
||||
const result = await command.handler(
|
||||
createCommandContext({
|
||||
channel: "webchat",
|
||||
gatewayClientScopes: ["operator.write", "operator.pairing"],
|
||||
}),
|
||||
);
|
||||
const text = requireText(result);
|
||||
|
||||
expect(pluginApiMocks.renderQrPngBase64).toHaveBeenCalledTimes(1);
|
||||
@@ -208,7 +213,12 @@ describe("device-pair /pair qr", () => {
|
||||
pluginApiMocks.renderQrPngBase64.mockRejectedValueOnce(new Error("render failed"));
|
||||
|
||||
const command = registerPairCommand();
|
||||
const result = await command.handler(createCommandContext({ channel: "webchat" }));
|
||||
const result = await command.handler(
|
||||
createCommandContext({
|
||||
channel: "webchat",
|
||||
gatewayClientScopes: ["operator.write", "operator.pairing"],
|
||||
}),
|
||||
);
|
||||
const text = requireText(result);
|
||||
|
||||
expect(pluginApiMocks.revokeDeviceBootstrapToken).toHaveBeenCalledWith({
|
||||
@@ -426,6 +436,23 @@ describe("device-pair /pair qr", () => {
|
||||
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for cleanup when internal gateway scopes are absent", async () => {
|
||||
const command = registerPairCommand();
|
||||
const result = await command.handler(
|
||||
createCommandContext({
|
||||
channel: "webchat",
|
||||
args: "cleanup",
|
||||
commandBody: "/pair cleanup",
|
||||
gatewayClientScopes: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(pluginApiMocks.clearDeviceBootstrapTokens).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("device-pair /pair default setup code", () => {
|
||||
@@ -470,6 +497,23 @@ describe("device-pair /pair default setup code", () => {
|
||||
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for webchat setup code issuance when scopes are absent", async () => {
|
||||
const command = registerPairCommand();
|
||||
const result = await command.handler(
|
||||
createCommandContext({
|
||||
channel: "webchat",
|
||||
args: "",
|
||||
commandBody: "/pair",
|
||||
gatewayClientScopes: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("device-pair notify pending formatting", () => {
|
||||
@@ -655,7 +699,44 @@ describe("device-pair /pair approve", () => {
|
||||
expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." });
|
||||
});
|
||||
|
||||
it("rejects approvals above the caller scopes", async () => {
|
||||
it("fails closed for approvals when internal gateway scopes are absent", async () => {
|
||||
vi.mocked(listDevicePairing).mockResolvedValueOnce({
|
||||
pending: [
|
||||
{
|
||||
requestId: "req-1",
|
||||
deviceId: "victim-phone",
|
||||
publicKey: "victim-public-key",
|
||||
displayName: "Victim Phone",
|
||||
platform: "ios",
|
||||
ts: Date.now(),
|
||||
},
|
||||
],
|
||||
paired: [],
|
||||
});
|
||||
vi.mocked(approveDevicePairing).mockResolvedValueOnce({
|
||||
status: "forbidden",
|
||||
missingScope: "operator.admin",
|
||||
});
|
||||
|
||||
const command = registerPairCommand();
|
||||
const result = await command.handler(
|
||||
createCommandContext({
|
||||
channel: "webchat",
|
||||
args: "approve latest",
|
||||
commandBody: "/pair approve latest",
|
||||
gatewayClientScopes: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", {
|
||||
callerScopes: [],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
text: "⚠️ This command requires operator.admin to approve this pairing request.",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects approvals that request scopes above the caller session", async () => {
|
||||
vi.mocked(listDevicePairing).mockResolvedValueOnce({
|
||||
pending: [
|
||||
{
|
||||
@@ -684,8 +765,11 @@ describe("device-pair /pair approve", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", {
|
||||
callerScopes: ["operator.write", "operator.pairing"],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
text: "⚠️ Cannot approve a request requiring operator.admin.",
|
||||
text: "⚠️ This command requires operator.admin to approve this pairing request.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -487,12 +487,35 @@ function buildMissingPairingScopeReply(): { text: string } {
|
||||
};
|
||||
}
|
||||
|
||||
function isMissingPairingScope(gatewayClientScopes: string[] | null): boolean {
|
||||
return Boolean(
|
||||
gatewayClientScopes &&
|
||||
!gatewayClientScopes.includes("operator.pairing") &&
|
||||
!gatewayClientScopes.includes("operator.admin"),
|
||||
);
|
||||
function isInternalGatewayPairingCaller(params: {
|
||||
channel: string;
|
||||
gatewayClientScopes?: readonly string[] | null;
|
||||
}): boolean {
|
||||
return params.channel === "webchat" || Array.isArray(params.gatewayClientScopes);
|
||||
}
|
||||
|
||||
function isMissingPairingScope(params: {
|
||||
channel: string;
|
||||
gatewayClientScopes?: readonly string[] | null;
|
||||
}): boolean {
|
||||
if (!isInternalGatewayPairingCaller(params)) {
|
||||
return false;
|
||||
}
|
||||
const gatewayClientScopes = params.gatewayClientScopes;
|
||||
return !Array.isArray(gatewayClientScopes)
|
||||
? true
|
||||
: !gatewayClientScopes.includes("operator.pairing") &&
|
||||
!gatewayClientScopes.includes("operator.admin");
|
||||
}
|
||||
|
||||
function resolveApprovalCallerScopes(params: {
|
||||
channel: string;
|
||||
gatewayClientScopes?: readonly string[] | null;
|
||||
}): readonly string[] | undefined {
|
||||
if (!isInternalGatewayPairingCaller(params)) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.isArray(params.gatewayClientScopes) ? params.gatewayClientScopes : [];
|
||||
}
|
||||
|
||||
const PAIR_SETUP_NON_ISSUING_ACTIONS = new Set([
|
||||
@@ -569,7 +592,7 @@ export default definePluginEntry({
|
||||
const action = tokens[0]?.toLowerCase() ?? "";
|
||||
const gatewayClientScopes = Array.isArray(ctx.gatewayClientScopes)
|
||||
? ctx.gatewayClientScopes
|
||||
: null;
|
||||
: undefined;
|
||||
api.logger.info?.(
|
||||
`device-pair: /pair invoked channel=${ctx.channel} sender=${ctx.senderId ?? "unknown"} action=${
|
||||
action || "new"
|
||||
@@ -591,7 +614,7 @@ export default definePluginEntry({
|
||||
}
|
||||
|
||||
if (action === "approve") {
|
||||
if (isMissingPairingScope(gatewayClientScopes)) {
|
||||
if (isMissingPairingScope({ channel: ctx.channel, gatewayClientScopes })) {
|
||||
return buildMissingPairingScopeReply();
|
||||
}
|
||||
const requested = tokens[1]?.trim();
|
||||
@@ -622,16 +645,21 @@ export default definePluginEntry({
|
||||
if (!pending) {
|
||||
return { text: "Pairing request not found." };
|
||||
}
|
||||
const approved = gatewayClientScopes
|
||||
? await approveDevicePairing(pending.requestId, {
|
||||
callerScopes: gatewayClientScopes,
|
||||
})
|
||||
: await approveDevicePairing(pending.requestId);
|
||||
const callerScopes = resolveApprovalCallerScopes({
|
||||
channel: ctx.channel,
|
||||
gatewayClientScopes,
|
||||
});
|
||||
const approved =
|
||||
callerScopes === undefined
|
||||
? await approveDevicePairing(pending.requestId)
|
||||
: await approveDevicePairing(pending.requestId, { callerScopes });
|
||||
if (!approved) {
|
||||
return { text: "Pairing request not found." };
|
||||
}
|
||||
if (approved.status === "forbidden") {
|
||||
return { text: `⚠️ Cannot approve a request requiring ${approved.missingScope}.` };
|
||||
return {
|
||||
text: `⚠️ This command requires ${approved.missingScope} to approve this pairing request.`,
|
||||
};
|
||||
}
|
||||
const label = approved.device.displayName?.trim() || approved.device.deviceId;
|
||||
const platform = approved.device.platform?.trim();
|
||||
@@ -640,7 +668,7 @@ export default definePluginEntry({
|
||||
}
|
||||
|
||||
if (action === "cleanup" || action === "clear" || action === "revoke") {
|
||||
if (isMissingPairingScope(gatewayClientScopes)) {
|
||||
if (isMissingPairingScope({ channel: ctx.channel, gatewayClientScopes })) {
|
||||
return buildMissingPairingScopeReply();
|
||||
}
|
||||
const cleared = await clearDeviceBootstrapTokens();
|
||||
@@ -656,7 +684,10 @@ export default definePluginEntry({
|
||||
if (authLabelResult.error) {
|
||||
return { text: `Error: ${authLabelResult.error}` };
|
||||
}
|
||||
if (issuesPairSetupCode(action) && isMissingPairingScope(gatewayClientScopes)) {
|
||||
if (
|
||||
issuesPairSetupCode(action) &&
|
||||
isMissingPairingScope({ channel: ctx.channel, gatewayClientScopes })
|
||||
) {
|
||||
return buildMissingPairingScopeReply();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user