mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 15:08:25 +00:00
fix(security): harden channel token and id generation
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
import type { TypingMode } from "../../config/types.js";
|
||||
import { emitAgentEvent } from "../../infra/agent-events.js";
|
||||
import { emitDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||
import { generateSecureUuid } from "../../infra/secure-random.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js";
|
||||
@@ -289,7 +289,7 @@ export async function runReplyAgent(params: {
|
||||
return false;
|
||||
}
|
||||
const prevSessionId = cleanupTranscripts ? prevEntry.sessionId : undefined;
|
||||
const nextSessionId = crypto.randomUUID();
|
||||
const nextSessionId = generateSecureUuid();
|
||||
const nextEntry: SessionEntry = {
|
||||
...prevEntry,
|
||||
sessionId: nextSessionId,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { generateSecureUuid } from "../secure-random.js";
|
||||
import type { OutboundChannel } from "./targets.js";
|
||||
|
||||
const QUEUE_DIRNAME = "delivery-queue";
|
||||
@@ -83,7 +83,7 @@ export async function enqueueDelivery(
|
||||
stateDir?: string,
|
||||
): Promise<string> {
|
||||
const queueDir = await ensureQueueDir(stateDir);
|
||||
const id = crypto.randomUUID();
|
||||
const id = generateSecureUuid();
|
||||
const entry: QueuedDelivery = {
|
||||
id,
|
||||
enqueuedAt: Date.now(),
|
||||
|
||||
20
src/infra/secure-random.test.ts
Normal file
20
src/infra/secure-random.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateSecureToken, generateSecureUuid } from "./secure-random.js";
|
||||
|
||||
describe("secure-random", () => {
|
||||
it("generates UUIDs", () => {
|
||||
const first = generateSecureUuid();
|
||||
const second = generateSecureUuid();
|
||||
expect(first).not.toBe(second);
|
||||
expect(first).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("generates url-safe tokens", () => {
|
||||
const defaultToken = generateSecureToken();
|
||||
const token18 = generateSecureToken(18);
|
||||
expect(defaultToken).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(token18).toMatch(/^[A-Za-z0-9_-]{24}$/);
|
||||
});
|
||||
});
|
||||
9
src/infra/secure-random.ts
Normal file
9
src/infra/secure-random.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
|
||||
export function generateSecureUuid(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
export function generateSecureToken(bytes = 16): string {
|
||||
return randomBytes(bytes).toString("base64url");
|
||||
}
|
||||
@@ -504,9 +504,10 @@ describe("Slack native command argument menus", () => {
|
||||
const element = actions?.elements?.[0];
|
||||
expect(element?.type).toBe("external_select");
|
||||
expect(element?.action_id).toBe("openclaw_cmdarg");
|
||||
expect(payload.blocks?.find((block) => block.type === "actions")?.block_id).toContain(
|
||||
"openclaw_cmdarg_ext:",
|
||||
);
|
||||
const blockId = payload.blocks?.find((block) => block.type === "actions")?.block_id;
|
||||
expect(blockId).toContain("openclaw_cmdarg_ext:");
|
||||
const token = (blockId ?? "").slice("openclaw_cmdarg_ext:".length);
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]{24}$/);
|
||||
});
|
||||
|
||||
it("serves filtered options for external_select menus", async () => {
|
||||
@@ -536,6 +537,28 @@ describe("Slack native command argument menus", () => {
|
||||
expect(optionTexts.some((text) => text.includes("Period 12"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects external_select option requests without user identity", async () => {
|
||||
const { respond } = await runCommandHandler(reportExternalHandler);
|
||||
|
||||
const payload = respond.mock.calls[0]?.[0] as {
|
||||
blocks?: Array<{ type: string; block_id?: string }>;
|
||||
};
|
||||
const blockId = payload.blocks?.find((block) => block.type === "actions")?.block_id;
|
||||
expect(blockId).toContain("openclaw_cmdarg_ext:");
|
||||
|
||||
const ackOptions = vi.fn().mockResolvedValue(undefined);
|
||||
await argMenuOptionsHandler({
|
||||
ack: ackOptions,
|
||||
body: {
|
||||
value: "period 1",
|
||||
actions: [{ block_id: blockId }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(ackOptions).toHaveBeenCalledTimes(1);
|
||||
expect(ackOptions).toHaveBeenCalledWith({ options: [] });
|
||||
});
|
||||
|
||||
it("rejects menu clicks from other users", async () => {
|
||||
const respond = await runArgMenuAction(argMenuHandler, {
|
||||
action: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
||||
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js";
|
||||
import { danger, logVerbose } from "../../globals.js";
|
||||
import { generateSecureToken } from "../../infra/secure-random.js";
|
||||
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
||||
import {
|
||||
readChannelAllowFromStore,
|
||||
@@ -37,6 +38,7 @@ const SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX = 100;
|
||||
const SLACK_COMMAND_ARG_SELECT_OPTION_VALUE_MAX = 75;
|
||||
const SLACK_COMMAND_ARG_EXTERNAL_PREFIX = "openclaw_cmdarg_ext:";
|
||||
const SLACK_COMMAND_ARG_EXTERNAL_TTL_MS = 10 * 60 * 1000;
|
||||
const SLACK_COMMAND_ARG_EXTERNAL_TOKEN_PATTERN = /^[A-Za-z0-9_-]{24}$/;
|
||||
const SLACK_HEADER_TEXT_MAX = 150;
|
||||
|
||||
type EncodedMenuChoice = { label: string; value: string };
|
||||
@@ -78,12 +80,21 @@ function pruneSlackExternalArgMenuStore(now = Date.now()) {
|
||||
}
|
||||
}
|
||||
|
||||
function createSlackExternalArgMenuToken(): string {
|
||||
// 18 bytes -> 24 base64url chars; loop avoids replacing an existing live token.
|
||||
let token = "";
|
||||
do {
|
||||
token = generateSecureToken(18);
|
||||
} while (slackExternalArgMenuStore.has(token));
|
||||
return token;
|
||||
}
|
||||
|
||||
function storeSlackExternalArgMenu(params: {
|
||||
choices: EncodedMenuChoice[];
|
||||
userId: string;
|
||||
}): string {
|
||||
pruneSlackExternalArgMenuStore();
|
||||
const token = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
||||
const token = createSlackExternalArgMenuToken();
|
||||
slackExternalArgMenuStore.set(token, {
|
||||
choices: params.choices,
|
||||
userId: params.userId,
|
||||
@@ -97,7 +108,7 @@ function readSlackExternalArgMenuToken(raw: unknown): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
const token = raw.slice(SLACK_COMMAND_ARG_EXTERNAL_PREFIX.length).trim();
|
||||
return token.length > 0 ? token : undefined;
|
||||
return SLACK_COMMAND_ARG_EXTERNAL_TOKEN_PATTERN.test(token) ? token : undefined;
|
||||
}
|
||||
|
||||
type CommandsRegistry = typeof import("../../auto-reply/commands-registry.js");
|
||||
@@ -783,7 +794,8 @@ export async function registerSlackMonitorSlashCommands(params: {
|
||||
await ack({ options: [] });
|
||||
return;
|
||||
}
|
||||
if (typedBody.user?.id && typedBody.user.id !== entry.userId) {
|
||||
const requesterUserId = typedBody.user?.id?.trim();
|
||||
if (!requesterUserId || requesterUserId !== entry.userId) {
|
||||
await ack({ options: [] });
|
||||
return;
|
||||
}
|
||||
@@ -860,7 +872,7 @@ export async function registerSlackMonitorSlashCommands(params: {
|
||||
user_name: userName,
|
||||
channel_id: body.channel?.id ?? "",
|
||||
channel_name: body.channel?.name ?? body.channel?.id ?? "",
|
||||
trigger_id: triggerId ?? String(Date.now()),
|
||||
trigger_id: triggerId,
|
||||
} as SlackCommandMiddlewareArgs["command"];
|
||||
await handleSlashCommand({
|
||||
command: commandPayload,
|
||||
|
||||
Reference in New Issue
Block a user