fix(slack): gate interactive system events by sender auth

This commit is contained in:
Peter Steinberger
2026-02-26 02:11:36 +01:00
parent 5e1bfb2ce2
commit ce8c67c314
6 changed files with 415 additions and 20 deletions

View File

@@ -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" }),
},
},
});