mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(security): unify secure id paths and guard weak patterns
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
68
src/security/weak-random-patterns.test.ts
Normal file
68
src/security/weak-random-patterns.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
69
src/slack/monitor/external-arg-menu-store.ts
Normal file
69
src/slack/monitor/external-arg-menu-store.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user