mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 04:34:32 +00:00
fix(slack): gate interactive system events by sender auth
This commit is contained in:
@@ -30,6 +30,7 @@ type RegisteredViewHandler = (args: {
|
||||
view?: {
|
||||
id?: string;
|
||||
callback_id?: string;
|
||||
private_metadata?: string;
|
||||
root_view_id?: string;
|
||||
previous_view_id?: string;
|
||||
external_id?: string;
|
||||
@@ -58,7 +59,23 @@ type RegisteredViewClosedHandler = (args: {
|
||||
};
|
||||
}) => Promise<void>;
|
||||
|
||||
function createContext() {
|
||||
function createContext(overrides?: {
|
||||
dmEnabled?: boolean;
|
||||
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
|
||||
allowFrom?: string[];
|
||||
allowNameMatching?: boolean;
|
||||
channelsConfig?: Record<string, { users?: string[] }>;
|
||||
isChannelAllowed?: (params: {
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
channelType?: "im" | "mpim" | "channel" | "group";
|
||||
}) => boolean;
|
||||
resolveUserName?: (userId: string) => Promise<{ name?: string }>;
|
||||
resolveChannelName?: (channelId: string) => Promise<{
|
||||
name?: string;
|
||||
type?: "im" | "mpim" | "channel" | "group";
|
||||
}>;
|
||||
}) {
|
||||
let handler: RegisteredHandler | null = null;
|
||||
let viewHandler: RegisteredViewHandler | null = null;
|
||||
let viewClosedHandler: RegisteredViewClosedHandler | null = null;
|
||||
@@ -80,9 +97,40 @@ function createContext() {
|
||||
};
|
||||
const runtimeLog = vi.fn();
|
||||
const resolveSessionKey = vi.fn().mockReturnValue("agent:ops:slack:channel:C1");
|
||||
const isChannelAllowed = vi
|
||||
.fn<
|
||||
(params: {
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
channelType?: "im" | "mpim" | "channel" | "group";
|
||||
}) => boolean
|
||||
>()
|
||||
.mockImplementation((params) => overrides?.isChannelAllowed?.(params) ?? true);
|
||||
const resolveUserName = vi
|
||||
.fn<(userId: string) => Promise<{ name?: string }>>()
|
||||
.mockImplementation((userId) => overrides?.resolveUserName?.(userId) ?? Promise.resolve({}));
|
||||
const resolveChannelName = vi
|
||||
.fn<
|
||||
(channelId: string) => Promise<{
|
||||
name?: string;
|
||||
type?: "im" | "mpim" | "channel" | "group";
|
||||
}>
|
||||
>()
|
||||
.mockImplementation(
|
||||
(channelId) => overrides?.resolveChannelName?.(channelId) ?? Promise.resolve({}),
|
||||
);
|
||||
const ctx = {
|
||||
app,
|
||||
runtime: { log: runtimeLog },
|
||||
dmEnabled: overrides?.dmEnabled ?? true,
|
||||
dmPolicy: overrides?.dmPolicy ?? ("open" as const),
|
||||
allowFrom: overrides?.allowFrom ?? [],
|
||||
allowNameMatching: overrides?.allowNameMatching ?? false,
|
||||
channelsConfig: overrides?.channelsConfig ?? {},
|
||||
defaultRequireMention: true,
|
||||
isChannelAllowed,
|
||||
resolveUserName,
|
||||
resolveChannelName,
|
||||
resolveSlackSystemEventSessionKey: resolveSessionKey,
|
||||
};
|
||||
return {
|
||||
@@ -90,6 +138,9 @@ function createContext() {
|
||||
app,
|
||||
runtimeLog,
|
||||
resolveSessionKey,
|
||||
isChannelAllowed,
|
||||
resolveUserName,
|
||||
resolveChannelName,
|
||||
getHandler: () => handler,
|
||||
getViewHandler: () => viewHandler,
|
||||
getViewClosedHandler: () => viewClosedHandler,
|
||||
@@ -168,7 +219,7 @@ describe("registerSlackInteractionEvents", () => {
|
||||
});
|
||||
expect(resolveSessionKey).toHaveBeenCalledWith({
|
||||
channelId: "C1",
|
||||
channelType: undefined,
|
||||
channelType: "channel",
|
||||
});
|
||||
expect(app.client.chat.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -228,6 +279,85 @@ describe("registerSlackInteractionEvents", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks block actions from users outside configured channel users allowlist", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, app, getHandler } = createContext({
|
||||
channelsConfig: {
|
||||
C1: { users: ["U_ALLOWED"] },
|
||||
},
|
||||
});
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
const handler = getHandler();
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler!({
|
||||
ack,
|
||||
respond,
|
||||
body: {
|
||||
user: { id: "U_DENIED" },
|
||||
channel: { id: "C1" },
|
||||
message: {
|
||||
ts: "201.202",
|
||||
blocks: [{ type: "actions", block_id: "verify_block", elements: [] }],
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "button",
|
||||
action_id: "openclaw:verify",
|
||||
block_id: "verify_block",
|
||||
},
|
||||
});
|
||||
|
||||
expect(ack).toHaveBeenCalled();
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expect(app.client.chat.update).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith({
|
||||
text: "You are not authorized to use this control.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks DM block actions when sender is not in allowFrom", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, app, getHandler } = createContext({
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["U_OWNER"],
|
||||
});
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
const handler = getHandler();
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler!({
|
||||
ack,
|
||||
respond,
|
||||
body: {
|
||||
user: { id: "U_ATTACKER" },
|
||||
channel: { id: "D222" },
|
||||
message: {
|
||||
ts: "301.302",
|
||||
blocks: [{ type: "actions", block_id: "verify_block", elements: [] }],
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "button",
|
||||
action_id: "openclaw:verify",
|
||||
block_id: "verify_block",
|
||||
},
|
||||
});
|
||||
|
||||
expect(ack).toHaveBeenCalled();
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expect(app.client.chat.update).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith({
|
||||
text: "You are not authorized to use this control.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores malformed action payloads after ack and logs warning", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, app, getHandler, runtimeLog } = createContext();
|
||||
@@ -338,7 +468,7 @@ describe("registerSlackInteractionEvents", () => {
|
||||
expect(ack).toHaveBeenCalled();
|
||||
expect(resolveSessionKey).toHaveBeenCalledWith({
|
||||
channelId: "C222",
|
||||
channelType: undefined,
|
||||
channelType: "channel",
|
||||
});
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
||||
const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string];
|
||||
@@ -697,7 +827,11 @@ describe("registerSlackInteractionEvents", () => {
|
||||
previous_view_id: "VPREV",
|
||||
external_id: "deploy-ext-1",
|
||||
hash: "view-hash-1",
|
||||
private_metadata: JSON.stringify({ channelId: "D123", channelType: "im" }),
|
||||
private_metadata: JSON.stringify({
|
||||
channelId: "D123",
|
||||
channelType: "im",
|
||||
userId: "U777",
|
||||
}),
|
||||
state: {
|
||||
values: {
|
||||
env_block: {
|
||||
@@ -771,6 +905,59 @@ describe("registerSlackInteractionEvents", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks modal events when private metadata userId does not match submitter", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, getViewHandler } = createContext();
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
const viewHandler = getViewHandler();
|
||||
expect(viewHandler).toBeTruthy();
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
await viewHandler!({
|
||||
ack,
|
||||
body: {
|
||||
user: { id: "U222" },
|
||||
view: {
|
||||
callback_id: "openclaw:deploy_form",
|
||||
private_metadata: JSON.stringify({
|
||||
channelId: "D123",
|
||||
channelType: "im",
|
||||
userId: "U111",
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(ack).toHaveBeenCalled();
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks modal events when private metadata is missing userId", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, getViewHandler } = createContext();
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
const viewHandler = getViewHandler();
|
||||
expect(viewHandler).toBeTruthy();
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
await viewHandler!({
|
||||
ack,
|
||||
body: {
|
||||
user: { id: "U222" },
|
||||
view: {
|
||||
callback_id: "openclaw:deploy_form",
|
||||
private_metadata: JSON.stringify({
|
||||
channelId: "D123",
|
||||
channelType: "im",
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(ack).toHaveBeenCalled();
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("captures modal input labels and picker values across block types", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, getViewHandler } = createContext();
|
||||
@@ -786,6 +973,7 @@ describe("registerSlackInteractionEvents", () => {
|
||||
view: {
|
||||
id: "V400",
|
||||
callback_id: "openclaw:routing_form",
|
||||
private_metadata: JSON.stringify({ userId: "U444" }),
|
||||
state: {
|
||||
values: {
|
||||
env_block: {
|
||||
@@ -1001,6 +1189,7 @@ describe("registerSlackInteractionEvents", () => {
|
||||
view: {
|
||||
id: "V555",
|
||||
callback_id: "openclaw:long_richtext",
|
||||
private_metadata: JSON.stringify({ userId: "U555" }),
|
||||
state: {
|
||||
values: {
|
||||
richtext_block: {
|
||||
@@ -1054,7 +1243,10 @@ describe("registerSlackInteractionEvents", () => {
|
||||
previous_view_id: "VPREV900",
|
||||
external_id: "deploy-ext-900",
|
||||
hash: "view-hash-900",
|
||||
private_metadata: JSON.stringify({ sessionKey: "agent:main:slack:channel:C99" }),
|
||||
private_metadata: JSON.stringify({
|
||||
sessionKey: "agent:main:slack:channel:C99",
|
||||
userId: "U900",
|
||||
}),
|
||||
state: {
|
||||
values: {
|
||||
env_block: {
|
||||
@@ -1101,7 +1293,10 @@ describe("registerSlackInteractionEvents", () => {
|
||||
viewId: "V900",
|
||||
userId: "U900",
|
||||
isCleared: true,
|
||||
privateMetadata: JSON.stringify({ sessionKey: "agent:main:slack:channel:C99" }),
|
||||
privateMetadata: JSON.stringify({
|
||||
sessionKey: "agent:main:slack:channel:C99",
|
||||
userId: "U900",
|
||||
}),
|
||||
rootViewId: "VROOT900",
|
||||
previousViewId: "VPREV900",
|
||||
externalId: "deploy-ext-900",
|
||||
@@ -1131,6 +1326,7 @@ describe("registerSlackInteractionEvents", () => {
|
||||
view: {
|
||||
id: "V901",
|
||||
callback_id: "openclaw:deploy_form",
|
||||
private_metadata: JSON.stringify({ userId: "U901" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user