refactor(security): unify secure id paths and guard weak patterns

This commit is contained in:
Peter Steinberger
2026-02-22 10:14:55 +01:00
parent ae8d4a8eec
commit 6c2e999776
12 changed files with 167 additions and 54 deletions

View File

@@ -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.

View File

@@ -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()}`;
}
/**

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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,

View File

@@ -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<string> {
@@ -13,7 +14,7 @@ export async function movePathToTrash(targetPath: string): Promise<string> {
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;

View File

@@ -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;
}

View File

@@ -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([]);
});
});

View File

@@ -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<T = unknown>(
opts: SignalRpcOptions,
): Promise<T> {
const baseUrl = normalizeBaseUrl(opts.baseUrl);
const id = randomUUID();
const id = generateSecureUuid();
const body = JSON.stringify({
jsonrpc: "2.0",
method,

View File

@@ -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<string, SlackExternalArgMenuEntry>,
now: number,
): void {
for (const [token, entry] of store.entries()) {
if (entry.expiresAt <= now) {
store.delete(token);
}
}
}
function createSlackExternalArgMenuToken(store: Map<string, SlackExternalArgMenuEntry>): 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<string, SlackExternalArgMenuEntry>();
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);
},
};
}

View File

@@ -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) {

View File

@@ -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<void> {
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({