diff --git a/CHANGELOG.md b/CHANGELOG.md index f3c25c63d..91fdfe6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ Docs: https://docs.openclaw.ai - Security/Gateway: emit a startup security warning when insecure/dangerous config flags are enabled (including `gateway.controlUi.dangerouslyDisableDeviceAuth=true`) and point operators to `openclaw security audit`. - Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. This ships in the next npm release. Thanks @aether-ai-agent for reporting. - Security/Exec approvals: treat `env` and shell-dispatch wrappers as transparent during allowlist analysis on node-host and macOS companion paths so policy checks match the effective executable/inline shell payload instead of the wrapper binary, blocking wrapper-smuggled allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. -- Security/Channels: harden Slack external menu token handling by switching to CSPRNG tokens, validating token shape, requiring user identity for external option lookups, and avoiding fabricated timestamp `trigger_id` fallbacks; also switch Tlon Urbit channel IDs to CSPRNG UUIDs and centralize secure ID/token generation via shared infra helpers. +- Security/Channels: harden Slack external menu token handling by switching to CSPRNG tokens, validating token shape, requiring user identity for external option lookups, and avoiding fabricated timestamp `trigger_id` fallbacks; also switch Tlon Urbit channel IDs to CSPRNG UUIDs, centralize secure ID/token generation via shared infra helpers, and add a guardrail test to block new runtime `Date.now()+Math.random()` token/id patterns. - Telegram/WSL2: disable `autoSelectFamily` by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync `/proc/version` probes on fetch/send paths. (#21916) Thanks @MizukiMachine. - Telegram/Streaming: preserve archived draft preview mapping after flush and clean superseded reasoning preview bubbles so multi-message preview finals no longer cross-edit or orphan stale messages under send/rotation races. (#23202) Thanks @obviyus. - Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound `app.options` calls. (#23209) Thanks @0xgaia. diff --git a/extensions/twitch/src/utils/twitch.ts b/extensions/twitch/src/utils/twitch.ts index cb2667cb1..4cda51330 100644 --- a/extensions/twitch/src/utils/twitch.ts +++ b/extensions/twitch/src/utils/twitch.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto"; + /** * Twitch-specific utility functions */ @@ -40,7 +42,7 @@ export function missingTargetError(provider: string, hint?: string): Error { * @returns A unique message ID */ export function generateMessageId(): string { - return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; + return `${Date.now()}-${randomUUID()}`; } /** diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index b53b997a0..9734c73be 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -13,6 +13,7 @@ import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; import type { OpenClawConfig } from "../../config/config.js"; import { getMachineDisplayName } from "../../infra/machine-name.js"; +import { generateSecureToken } from "../../infra/secure-random.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; import { isCronSessionKey, isSubagentSessionKey } from "../../routing/session-key.js"; @@ -133,7 +134,7 @@ type CompactionMessageMetrics = { }; function createCompactionDiagId(): string { - return `cmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + return `cmp-${Date.now().toString(36)}-${generateSecureToken(4)}`; } function getMessageTextChars(msg: AgentMessage): number { diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index e7f57de8d..e396ca082 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; +import { generateSecureToken } from "../../infra/secure-random.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import type { PluginHookBeforeAgentStartResult } from "../../plugins/types.js"; import { enqueueCommandInLane } from "../../process/command-queue.js"; @@ -100,7 +101,7 @@ const createUsageAccumulator = (): UsageAccumulator => ({ }); function createCompactionDiagId(): string { - return `ovf-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + return `ovf-${Date.now().toString(36)}-${generateSecureToken(4)}`; } // Defensive guard for the outer run loop across all retry branches. diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 9a9a18340..9044abf51 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -6,6 +6,7 @@ import { getChannelDock } from "../../channels/dock.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; +import { generateSecureToken } from "../../infra/secure-random.js"; import { resolveGatewayMessageChannel } from "../../utils/message-channel.js"; import { listReservedChatSlashCommandNames, @@ -210,7 +211,7 @@ export async function handleInlineActions(params: { return { kind: "reply", reply: { text: `❌ Tool not available: ${dispatch.toolName}` } }; } - const toolCallId = `cmd_${Date.now()}_${Math.random().toString(16).slice(2)}`; + const toolCallId = `cmd_${generateSecureToken(8)}`; try { const result = await tool.execute(toolCallId, { command: rawArgs, diff --git a/src/browser/trash.ts b/src/browser/trash.ts index 5dcecbb10..c0b1d6094 100644 --- a/src/browser/trash.ts +++ b/src/browser/trash.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { generateSecureToken } from "../infra/secure-random.js"; import { runExec } from "../process/exec.js"; export async function movePathToTrash(targetPath: string): Promise { @@ -13,7 +14,7 @@ export async function movePathToTrash(targetPath: string): Promise { const base = path.basename(targetPath); let dest = path.join(trashDir, `${base}-${Date.now()}`); if (fs.existsSync(dest)) { - dest = path.join(trashDir, `${base}-${Date.now()}-${Math.random()}`); + dest = path.join(trashDir, `${base}-${Date.now()}-${generateSecureToken(6)}`); } fs.renameSync(targetPath, dest); return dest; diff --git a/src/commands/sessions.test-helpers.ts b/src/commands/sessions.test-helpers.ts index bd6b981ae..4c0d8b0c4 100644 --- a/src/commands/sessions.test-helpers.ts +++ b/src/commands/sessions.test-helpers.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -49,10 +50,7 @@ export function makeRuntime(params?: { throwOnError?: boolean }): { } export function writeStore(data: unknown, prefix = "sessions"): string { - const file = path.join( - os.tmpdir(), - `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, - ); + const file = path.join(os.tmpdir(), `${prefix}-${Date.now()}-${randomUUID()}.json`); fs.writeFileSync(file, JSON.stringify(data, null, 2)); return file; } diff --git a/src/security/weak-random-patterns.test.ts b/src/security/weak-random-patterns.test.ts new file mode 100644 index 000000000..fa1d0b342 --- /dev/null +++ b/src/security/weak-random-patterns.test.ts @@ -0,0 +1,68 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const SCAN_ROOTS = ["src", "extensions"] as const; +const SKIP_DIRS = new Set([".git", "dist", "node_modules"]); + +function collectTypeScriptFiles(rootDir: string): string[] { + const out: string[] = []; + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) { + stack.push(fullPath); + } + continue; + } + if (!entry.isFile()) { + continue; + } + if ( + !entry.name.endsWith(".ts") || + entry.name.endsWith(".test.ts") || + entry.name.endsWith(".d.ts") + ) { + continue; + } + out.push(fullPath); + } + } + return out; +} + +function findWeakRandomPatternMatches(repoRoot: string): string[] { + const matches: string[] = []; + for (const scanRoot of SCAN_ROOTS) { + const root = path.join(repoRoot, scanRoot); + if (!fs.existsSync(root)) { + continue; + } + const files = collectTypeScriptFiles(root); + for (const filePath of files) { + const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/); + for (let idx = 0; idx < lines.length; idx += 1) { + const line = lines[idx] ?? ""; + if (!line.includes("Date.now") || !line.includes("Math.random")) { + continue; + } + matches.push(`${path.relative(repoRoot, filePath)}:${idx + 1}`); + } + } + } + return matches; +} + +describe("weak random pattern guardrail", () => { + it("rejects Date.now + Math.random token/id patterns in runtime code", () => { + const repoRoot = path.resolve(process.cwd()); + const matches = findWeakRandomPatternMatches(repoRoot); + expect(matches).toEqual([]); + }); +}); diff --git a/src/signal/client.ts b/src/signal/client.ts index 35bb54c24..c92837b1b 100644 --- a/src/signal/client.ts +++ b/src/signal/client.ts @@ -1,5 +1,5 @@ -import { randomUUID } from "node:crypto"; import { resolveFetch } from "../infra/fetch.js"; +import { generateSecureUuid } from "../infra/secure-random.js"; import { fetchWithTimeout } from "../utils/fetch-timeout.js"; export type SignalRpcOptions = { @@ -53,7 +53,7 @@ export async function signalRpcRequest( opts: SignalRpcOptions, ): Promise { const baseUrl = normalizeBaseUrl(opts.baseUrl); - const id = randomUUID(); + const id = generateSecureUuid(); const body = JSON.stringify({ jsonrpc: "2.0", method, diff --git a/src/slack/monitor/external-arg-menu-store.ts b/src/slack/monitor/external-arg-menu-store.ts new file mode 100644 index 000000000..8ea66b2fe --- /dev/null +++ b/src/slack/monitor/external-arg-menu-store.ts @@ -0,0 +1,69 @@ +import { generateSecureToken } from "../../infra/secure-random.js"; + +const SLACK_EXTERNAL_ARG_MENU_TOKEN_BYTES = 18; +const SLACK_EXTERNAL_ARG_MENU_TOKEN_LENGTH = Math.ceil( + (SLACK_EXTERNAL_ARG_MENU_TOKEN_BYTES * 8) / 6, +); +const SLACK_EXTERNAL_ARG_MENU_TOKEN_PATTERN = new RegExp( + `^[A-Za-z0-9_-]{${SLACK_EXTERNAL_ARG_MENU_TOKEN_LENGTH}}$`, +); +const SLACK_EXTERNAL_ARG_MENU_TTL_MS = 10 * 60 * 1000; + +export const SLACK_EXTERNAL_ARG_MENU_PREFIX = "openclaw_cmdarg_ext:"; + +export type SlackExternalArgMenuChoice = { label: string; value: string }; +export type SlackExternalArgMenuEntry = { + choices: SlackExternalArgMenuChoice[]; + userId: string; + expiresAt: number; +}; + +function pruneSlackExternalArgMenuStore( + store: Map, + now: number, +): void { + for (const [token, entry] of store.entries()) { + if (entry.expiresAt <= now) { + store.delete(token); + } + } +} + +function createSlackExternalArgMenuToken(store: Map): string { + let token = ""; + do { + token = generateSecureToken(SLACK_EXTERNAL_ARG_MENU_TOKEN_BYTES); + } while (store.has(token)); + return token; +} + +export function createSlackExternalArgMenuStore() { + const store = new Map(); + + return { + create( + params: { choices: SlackExternalArgMenuChoice[]; userId: string }, + now = Date.now(), + ): string { + pruneSlackExternalArgMenuStore(store, now); + const token = createSlackExternalArgMenuToken(store); + store.set(token, { + choices: params.choices, + userId: params.userId, + expiresAt: now + SLACK_EXTERNAL_ARG_MENU_TTL_MS, + }); + return token; + }, + readToken(raw: unknown): string | undefined { + if (typeof raw !== "string" || !raw.startsWith(SLACK_EXTERNAL_ARG_MENU_PREFIX)) { + return undefined; + } + const token = raw.slice(SLACK_EXTERNAL_ARG_MENU_PREFIX.length).trim(); + return SLACK_EXTERNAL_ARG_MENU_TOKEN_PATTERN.test(token) ? token : undefined; + }, + get(token: string, now = Date.now()): SlackExternalArgMenuEntry | undefined { + pruneSlackExternalArgMenuStore(store, now); + return store.get(token); + }, + }; +} diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index e188f3bd8..f73c5bb92 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -5,7 +5,6 @@ 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, @@ -23,6 +22,11 @@ import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./ch import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js"; import type { SlackMonitorContext } from "./context.js"; import { normalizeSlackChannelType } from "./context.js"; +import { + createSlackExternalArgMenuStore, + SLACK_EXTERNAL_ARG_MENU_PREFIX, + type SlackExternalArgMenuChoice, +} from "./external-arg-menu-store.js"; import { escapeSlackMrkdwn } from "./mrkdwn.js"; import { isSlackChannelAllowedByPolicy } from "./policy.js"; import { resolveSlackRoomContextHints } from "./room-context.js"; @@ -36,16 +40,10 @@ const SLACK_COMMAND_ARG_OVERFLOW_MIN = 3; const SLACK_COMMAND_ARG_OVERFLOW_MAX = 5; 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 }; -const slackExternalArgMenuStore = new Map< - string, - { choices: EncodedMenuChoice[]; userId: string; expiresAt: number } ->(); +type EncodedMenuChoice = SlackExternalArgMenuChoice; +const slackExternalArgMenuStore = createSlackExternalArgMenuStore(); function truncatePlainText(value: string, max: number): string { const trimmed = value.trim(); @@ -72,43 +70,18 @@ function buildSlackArgMenuConfirm(params: { command: string; arg: string }) { }; } -function pruneSlackExternalArgMenuStore(now = Date.now()) { - for (const [token, entry] of slackExternalArgMenuStore.entries()) { - if (entry.expiresAt <= now) { - slackExternalArgMenuStore.delete(token); - } - } -} - -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 = createSlackExternalArgMenuToken(); - slackExternalArgMenuStore.set(token, { + return slackExternalArgMenuStore.create({ choices: params.choices, userId: params.userId, - expiresAt: Date.now() + SLACK_COMMAND_ARG_EXTERNAL_TTL_MS, }); - return token; } function readSlackExternalArgMenuToken(raw: unknown): string | undefined { - if (typeof raw !== "string" || !raw.startsWith(SLACK_COMMAND_ARG_EXTERNAL_PREFIX)) { - return undefined; - } - const token = raw.slice(SLACK_COMMAND_ARG_EXTERNAL_PREFIX.length).trim(); - return SLACK_COMMAND_ARG_EXTERNAL_TOKEN_PATTERN.test(token) ? token : undefined; + return slackExternalArgMenuStore.readToken(raw); } type CommandsRegistry = typeof import("../../auto-reply/commands-registry.js"); @@ -224,7 +197,7 @@ function buildSlackCommandArgMenuBlocks(params: { ? [ { type: "actions", - block_id: `${SLACK_COMMAND_ARG_EXTERNAL_PREFIX}${params.createExternalMenuToken( + block_id: `${SLACK_EXTERNAL_ARG_MENU_PREFIX}${params.createExternalMenuToken( encodedChoices, )}`, elements: [ @@ -782,7 +755,6 @@ export async function registerSlackMonitorSlashCommands(params: { actions?: Array<{ block_id?: string }>; block_id?: string; }; - pruneSlackExternalArgMenuStore(); const blockId = typedBody.actions?.[0]?.block_id ?? typedBody.block_id; const token = readSlackExternalArgMenuToken(blockId); if (!token) { diff --git a/src/web/outbound.ts b/src/web/outbound.ts index 5d3e84ba4..ce8b44669 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -1,6 +1,6 @@ -import { randomUUID } from "node:crypto"; import { loadConfig } from "../config/config.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; +import { generateSecureUuid } from "../infra/secure-random.js"; import { getChildLogger } from "../logging/logger.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { convertMarkdownTables } from "../markdown/tables.js"; @@ -24,7 +24,7 @@ export async function sendMessageWhatsApp( }, ): Promise<{ messageId: string; toJid: string }> { let text = body; - const correlationId = randomUUID(); + const correlationId = generateSecureUuid(); const startedAt = Date.now(); const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener( options.accountId, @@ -112,7 +112,7 @@ export async function sendReactionWhatsApp( accountId?: string; }, ): Promise { - const correlationId = randomUUID(); + const correlationId = generateSecureUuid(); const { listener: active } = requireActiveWebListener(options.accountId); const logger = getChildLogger({ module: "web-outbound", @@ -147,7 +147,7 @@ export async function sendPollWhatsApp( poll: PollInput, options: { verbose: boolean; accountId?: string }, ): Promise<{ messageId: string; toJid: string }> { - const correlationId = randomUUID(); + const correlationId = generateSecureUuid(); const startedAt = Date.now(); const { listener: active } = requireActiveWebListener(options.accountId); const logger = getChildLogger({