fix(slack): validate interaction payloads and handle malformed actions

This commit is contained in:
Peter Steinberger
2026-02-17 02:50:51 +01:00
parent bbb5fbc71f
commit d6226355e6
3 changed files with 66 additions and 5 deletions

View File

@@ -201,6 +201,12 @@ For actions/directory reads, user token can be preferred when configured. For wr
- Enable native Slack command handlers with `channels.slack.commands.native: true` (or global `commands.native: true`).
- When native commands are enabled, register matching slash commands in Slack (`/<command>` names).
- If native commands are not enabled, you can run a single configured slash command via `channels.slack.slashCommand`.
- Native arg menus now adapt their rendering strategy:
- up to 5 options: button blocks
- 6-100 options: static select menu
- more than 100 options: external select with async option filtering when interactivity options handlers are available
- if encoded option values exceed Slack limits, the flow falls back to buttons
- For long option payloads, Slash command argument menus use a confirm dialog before dispatching a selected value.
Default slash command settings:
@@ -286,6 +292,9 @@ Available action groups in current Slack tooling:
- Member join/leave, channel created/renamed, and pin add/remove events are mapped into system events.
- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled.
- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context.
- Block actions and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
- block actions: selected values, labels, picker values, and `workflow_*` metadata
- modal `view_submission` and `view_closed` events with routed channel metadata and form inputs
## Ack reactions

View File

@@ -228,6 +228,39 @@ describe("registerSlackInteractionEvents", () => {
);
});
it("ignores malformed action payloads after ack and logs warning", async () => {
const { ctx, app, getHandler, runtimeLog } = createContext();
registerSlackInteractionEvents({ ctx: ctx as never });
const handler = getHandler();
expect(handler).toBeTruthy();
const ack = vi.fn().mockResolvedValue(undefined);
await handler!({
ack,
body: {
user: { id: "U666" },
channel: { id: "C1" },
message: {
ts: "777.888",
text: "fallback",
blocks: [
{
type: "actions",
block_id: "verify_block",
elements: [{ type: "button", action_id: "openclaw:verify" }],
},
],
},
},
action: "not-an-action-object" as unknown as Record<string, unknown>,
});
expect(ack).toHaveBeenCalled();
expect(app.client.chat.update).not.toHaveBeenCalled();
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(runtimeLog).toHaveBeenCalledWith(expect.stringContaining("slack:interaction malformed"));
});
it("escapes mrkdwn characters in confirmation labels", async () => {
enqueueSystemEventMock.mockReset();
const { ctx, app, getHandler } = createContext();

View File

@@ -1,8 +1,8 @@
import type { SlackActionMiddlewareArgs } from "@slack/bolt";
import type { Block, KnownBlock } from "@slack/web-api";
import type { SlackMonitorContext } from "../context.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
import { parseSlackModalPrivateMetadata } from "../../modal-metadata.js";
import type { SlackMonitorContext } from "../context.js";
// Prefix for OpenClaw-generated action IDs to scope our handler
const OPENCLAW_ACTION_PREFIX = "openclaw:";
@@ -135,6 +135,13 @@ function summarizeRichTextPreview(value: unknown): string | undefined {
return joined.length <= max ? joined : `${joined.slice(0, max - 1)}`;
}
function readInteractionAction(raw: unknown) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
return raw as Record<string, unknown>;
}
function summarizeAction(
action: Record<string, unknown>,
): Omit<InteractionSummary, "actionId" | "blockId"> {
@@ -394,14 +401,26 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
await ack();
// Extract action details using proper Bolt types
const typedAction = action as unknown as Record<string, unknown> & {
const typedAction = readInteractionAction(action);
if (!typedAction) {
ctx.runtime.log?.(
`slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${
typedBody.user?.id ?? "unknown"
}`,
);
return;
}
const typedActionWithText = typedAction as {
action_id?: string;
block_id?: string;
type?: string;
text?: { text?: string };
};
const actionId = typedAction.action_id ?? "unknown";
const blockId = typedAction.block_id;
const actionId =
typeof typedActionWithText.action_id === "string"
? typedActionWithText.action_id
: "unknown";
const blockId = typedActionWithText.block_id;
const userId = typedBody.user?.id ?? "unknown";
const channelId = typedBody.channel?.id ?? typedBody.container?.channel_id;
const messageTs = typedBody.message?.ts ?? typedBody.container?.message_ts;
@@ -454,7 +473,7 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
const selectedLabel = formatInteractionSelectionLabel({
actionId,
summary: actionSummary,
buttonText: typedAction.text?.text,
buttonText: typedActionWithText.text?.text,
});
let updatedBlocks = originalBlocks.map((block) => {
const typedBlock = block as InteractionMessageBlock;