From 4c4c44a4a36c14bb7b27af9830c83b1080a043c0 Mon Sep 17 00:00:00 2001 From: JOM Date: Thu, 6 Aug 2026 10:47:38 +0800 Subject: [PATCH] fix: harden WeChat bridge security boundaries --- README.md | 28 ++++++-- src/auth.ts | 29 ++++++++ src/client.ts | 54 +++++++++++--- src/commands.ts | 9 ++- src/index.ts | 77 +++++--------------- src/remote-commands.ts | 6 +- src/security.ts | 130 ++++++++++++++++++++++++++++++++++ tests/client-security.test.ts | 82 +++++++++++++++++++++ tests/remote-commands.test.ts | 48 +++++++++++++ tests/security.test.ts | 113 +++++++++++++++++++++++++++++ 10 files changed, 503 insertions(+), 73 deletions(-) create mode 100644 src/security.ts create mode 100644 tests/client-security.test.ts create mode 100644 tests/remote-commands.test.ts create mode 100644 tests/security.test.ts diff --git a/README.md b/README.md index 958d340..6c6f37f 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Send text, voice, or images on WeChat to chat normally. Additional commands: | `/session` | Show session details | | `/help` | Show help | -Advanced: `/thinking`, `/tools`, `/compact`. +Advanced: `/thinking`, `/compact`. `/tools` is disabled for WeChat by default; it can only be enabled on a local Ubuntu host with `PI_WECHAT_ALLOW_REMOTE_TOOLS=1`. ## Supported Message Types @@ -119,6 +119,7 @@ Advanced: `/thinking`, `/tools`, `/compact`. | `PI_WECHAT_DEBUG_FILE` | Debug log file path | `~/.pi/agent/wechat-assistant/debug.log` | | `PI_WECHAT_IMAGE_BATCH_WAIT_MS` | Batch wait time for images | `8000` | | `PI_WECHAT_IMAGE_MAX_BYTES` | Per-image size limit | `52428800` (50 MB) | +| `PI_WECHAT_ALLOW_REMOTE_TOOLS` | Enable WeChat `/tools` only on local Ubuntu | Disabled | ### Config Files @@ -126,6 +127,7 @@ Advanced: `/thinking`, `/tools`, `/compact`. ~/.pi/agent/wechat-assistant/ ├── credentials.json # Login credentials (mode 600) ├── config.json # Auto-start, image limits +├── transport-state.json # iLink cursor and last 500 processed message IDs (mode 600) └── session.lock # Exclusive lock file ``` @@ -148,9 +150,17 @@ WeChat ⇄ pi TUI session ⇄ AI model + tools - WeChat messages are fetched via iLink Bot API long polling - Incoming messages are injected into the active pi session via `pi.sendUserMessage()` - When you type in TUI, a preview is sent to WeChat -- AI replies are delivered incrementally (per `message_end`) and finalized on `agent_end` +- AI replies are delivered only from assistant text emitted in the current turn's `message_end` - Only the TUI session that runs `/wechat start` holds the connection +## Security boundaries + +- Only the WeChat user ID embedded in the QR-login credential is accepted. Messages from every other ID are dropped before a context token is stored, a queue entry is created, or a reply is sent. +- The iLink cursor and the latest 500 processed message IDs are persisted in `transport-state.json`; a restart will not inject those messages again. `/wechat login --force` and `/wechat logout` reset this state. +- Replies are sent only from assistant text emitted in the current turn's `message_end`; restored session history is never replayed to WeChat. +- `send_file_to_wechat` and `send_image_to_wechat` accept only real, ordinary files inside the real project directory. Symbolic links are rejected. +- WeChat `/tools` is disabled by default. It requires `PI_WECHAT_ALLOW_REMOTE_TOOLS=1` on Ubuntu to opt in. + ## FAQ **WeChat not responding?** Run `/wechat status` to check. If the session expired, run `/wechat login --force`. @@ -262,7 +272,7 @@ pi install git:github.com/shenjiecode/pi-wechat-assistant | `/session` | 查看会话详情 | | `/help` | 显示帮助 | -高级命令:`/thinking`、`/tools`、`/compact`。 +高级命令:`/thinking`、`/compact`。微信端 `/tools` 默认禁用;仅 Ubuntu 本机设置 `PI_WECHAT_ALLOW_REMOTE_TOOLS=1` 后才可启用。 ## 支持的消息类型 @@ -285,6 +295,7 @@ pi install git:github.com/shenjiecode/pi-wechat-assistant | `PI_WECHAT_DEBUG_FILE` | 调试日志路径 | `~/.pi/agent/wechat-assistant/debug.log` | | `PI_WECHAT_IMAGE_BATCH_WAIT_MS` | 图片批量等待时间 | `8000` | | `PI_WECHAT_IMAGE_MAX_BYTES` | 单张图片大小上限 | `52428800`(50 MB) | +| `PI_WECHAT_ALLOW_REMOTE_TOOLS` | 仅 Ubuntu 本机启用微信 `/tools` | 默认禁用 | ### 配置文件 @@ -292,6 +303,7 @@ pi install git:github.com/shenjiecode/pi-wechat-assistant ~/.pi/agent/wechat-assistant/ ├── credentials.json # 登录凭证(权限 600) ├── config.json # 自动启动、图片限制 +├── transport-state.json # iLink 游标及最近 500 条消息 ID(权限 600) └── session.lock # 排他锁文件 ``` @@ -304,9 +316,17 @@ pi install git:github.com/shenjiecode/pi-wechat-assistant - 微信消息通过 iLink Bot API 长轮询获取 - 收到的消息通过 `pi.sendUserMessage()` 注入当前 pi 会话 - TUI 输入时微信端会收到预览 -- AI 回复增量发送(每条 `message_end` 即发),`agent_end` 时补发遗漏 +- AI 回复仅从当前 turn 的 `message_end` 产生的 assistant 文本发送 - 只有执行 `/wechat start` 的 TUI 会话持有连接 +## 安全边界 + +- 仅接受扫码凭证中绑定的微信用户 ID;其他 ID 的消息不会保存 context token、入队、注入 Agent 或自动回复。 +- iLink 游标和最近 500 条已处理 message ID 保存于 `transport-state.json`,重启后不会重复注入;`/wechat login --force` 与 `/wechat logout` 会清除它。 +- 仅转发当前 turn 的 `message_end` 实际产生的 assistant 文本,不会重发恢复 session 中的历史回复。 +- `send_file_to_wechat` 与 `send_image_to_wechat` 仅允许真实项目目录内的普通文件,符号链接一律拒绝。 +- 微信端 `/tools` 默认禁用,只有 Ubuntu 本机设置 `PI_WECHAT_ALLOW_REMOTE_TOOLS=1` 才能启用。 + ## 常见问题 **微信没有回复?** 执行 `/wechat status` 检查状态。Session 过期则执行 `/wechat login --force`。 diff --git a/src/auth.ts b/src/auth.ts index 4ae8d91..d42ccbb 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -6,6 +6,7 @@ import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' import { DEFAULT_BASE_URL, fetchQrCode, getQrCodeStatus, type QrStatusResponse } from './api.js' +import { MAX_RECENT_MESSAGE_IDS, recordProcessedMessageId } from './security.js' import type { Credentials } from './types.js' // --- 路径 --- @@ -15,6 +16,7 @@ const CREDS_FILE = path.join(STATE_DIR, 'credentials.json') const CONFIG_FILE = path.join(STATE_DIR, 'config.json') const LOCK_FILE = path.join(STATE_DIR, 'session.lock') const CONTEXT_TOKENS_FILE = path.join(STATE_DIR, 'context-tokens.json') +const TRANSPORT_STATE_FILE = path.join(STATE_DIR, 'transport-state.json') export function getStateDir(): string { return STATE_DIR @@ -42,6 +44,8 @@ async function readJsonFile(filePath: string): Promise { async function writeJsonFile(filePath: string, data: unknown): Promise { await ensureStateDir() await fs.writeFile(filePath, JSON.stringify(data, null, 2), { mode: 0o600 }) + // writeFile 的 mode 不会修改已有文件;明确收紧已有状态文件权限。 + await fs.chmod(filePath, 0o600) } async function deleteFile(filePath: string): Promise { @@ -70,6 +74,31 @@ export async function clearContextTokens(): Promise { await deleteFile(CONTEXT_TOKENS_FILE) } +// --- iLink 长轮询传输状态 --- + +export interface TransportState { + cursor: string + processedMessageIds: string[] +} + +export async function loadTransportState(): Promise { + const data = await readJsonFile>(TRANSPORT_STATE_FILE) + const ids = Array.isArray(data?.processedMessageIds) + ? data.processedMessageIds.filter((id): id is string => typeof id === 'string').slice(-MAX_RECENT_MESSAGE_IDS) + : [] + return { cursor: typeof data?.cursor === 'string' ? data.cursor : '', processedMessageIds: ids } +} + +export async function saveTransportState(state: TransportState): Promise { + let ids: string[] = [] + for (const id of state.processedMessageIds) ids = recordProcessedMessageId(ids, id) + await writeJsonFile(TRANSPORT_STATE_FILE, { cursor: state.cursor, processedMessageIds: ids }) +} + +export async function clearTransportState(): Promise { + await deleteFile(TRANSPORT_STATE_FILE) +} + // --- 配置 --- export interface BridgeConfig { diff --git a/src/client.ts b/src/client.ts index db2e383..24f2753 100644 --- a/src/client.ts +++ b/src/client.ts @@ -22,7 +22,15 @@ import { loadContextTokens, saveContextTokensThrottled, flushContextTokens, + loadTransportState, + saveTransportState, } from './auth.js' +import { + isAuthorizedWeChatSender, + isDuplicateMessageId, + MAX_RECENT_MESSAGE_IDS, + recordProcessedMessageId, +} from './security.js' import { CDN_BASE, STREAM_ENCRYPTION_THRESHOLD, @@ -128,6 +136,8 @@ export class WeixinClient { private readonly token: string private baseUrl: string private cursor = '' + private readonly processedMessageIds = new Set() + private processedMessageIdOrder: string[] = [] private readonly typingTickets = new Map() private readonly contextTokens = new Map() private _lastActiveUserId: string | null = null @@ -147,10 +157,15 @@ export class WeixinClient { } private async _init(): Promise { - const persisted = await loadContextTokens() - this._lastActiveUserId = persisted.lastUserId - for (const [userId, token] of Object.entries(persisted.tokens)) { - this.contextTokens.set(userId, token) + const [persisted, transport] = await Promise.all([loadContextTokens(), loadTransportState()]) + // Context token 只允许属于当前扫码凭证绑定用户,避免旧凭证残留跨用户复用。 + const token = persisted.tokens[this.userId] + if (token) this.contextTokens.set(this.userId, token) + this._lastActiveUserId = persisted.lastUserId === this.userId ? this.userId : null + this.cursor = transport.cursor + for (const id of transport.processedMessageIds) { + this.processedMessageIds.add(id) + this.processedMessageIdOrder.push(id) } } @@ -190,10 +205,22 @@ export class WeixinClient { const incoming: IncomingMessage[] = [] for (const raw of response.msgs ?? []) { + if (raw.message_type !== 1) continue + if (!isAuthorizedWeChatSender(raw.from_user_id, this.userId)) { + debugLog(`丢弃未授权微信消息: from=${raw.from_user_id ?? '(empty)'}`) + continue + } + const messageId = String(raw.message_id ?? '') + if (isDuplicateMessageId(this.processedMessageIds, messageId)) { + debugLog(`跳过重复微信消息: id=${messageId}`) + continue + } + this.rememberProcessedMessageId(messageId) this.rememberContext(raw) const normalized = this.normalizeIncomingMessage(raw) if (normalized) incoming.push(normalized) } + await saveTransportState({ cursor: this.cursor, processedMessageIds: this.processedMessageIdOrder }) return incoming } @@ -259,15 +286,26 @@ export class WeixinClient { // --- 上下文管理 --- rememberContext(raw: { from_user_id?: string; to_user_id?: string; context_token?: string; message_type?: number }): void { - const userId = raw.message_type === 1 ? raw.from_user_id : raw.to_user_id - if (userId && raw.context_token) { - this.contextTokens.set(userId, raw.context_token) - this._lastActiveUserId = userId + if (raw.message_type === 1 && isAuthorizedWeChatSender(raw.from_user_id, this.userId) && raw.context_token) { + this.contextTokens.set(this.userId, raw.context_token) + this._lastActiveUserId = this.userId this._contextTokensDirty = true this._schedulePersist() } } + private rememberProcessedMessageId(messageId: string): void { + if (!messageId) return + this.processedMessageIdOrder = recordProcessedMessageId(this.processedMessageIdOrder, messageId) + this.processedMessageIds.clear() + for (const id of this.processedMessageIdOrder) this.processedMessageIds.add(id) + // 类型层面同时固定容量,避免未来修改 record 函数时无界增长。 + while (this.processedMessageIdOrder.length > MAX_RECENT_MESSAGE_IDS) { + const removed = this.processedMessageIdOrder.shift() + if (removed) this.processedMessageIds.delete(removed) + } + } + private _persistTimer: ReturnType | null = null private _schedulePersist(): void { diff --git a/src/commands.ts b/src/commands.ts index 720b8f4..d96a522 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -8,6 +8,7 @@ import { acquireLock, clearCredentials, clearContextTokens, + clearTransportState, getCredentialsPath, getQrCode, loadConfig, @@ -57,8 +58,11 @@ async function cmdLogin(args: string, ctx: Ctx, deps: CommandDeps): Promise | null = null ended = false reset(): void { this.wechatConversationActive = false this.targetUser = null this.sentCount = 0 - this.messages = null this.ended = false } } -// ============================================================================ -// 路径沙箱校验 -// ============================================================================ - -function isPathInCwd(targetPath: string, cwd: string): boolean { - const resolved = path.resolve(targetPath) - const resolvedCwd = path.resolve(cwd) - return resolved.startsWith(resolvedCwd + path.sep) || resolved === resolvedCwd -} - // ============================================================================ // 工具守卫 — 发送文件/图片到微信的前置校验 // ============================================================================ @@ -71,29 +60,20 @@ type ToolGuardResult = { cwd: string } -function guardSendToWechat( +async function guardSendToWechat( client: WeixinClient | null, running: boolean, lastWechatUser: { userId: string } | null, filePath: string, latestCtx: Ctx | null, -): ToolGuardResult { +): Promise { if (!client) return { allowed: false, error: fail('微信未登录,请先在 TUI 执行 /wechat login 和 /wechat start') } if (!running) return { allowed: false, error: fail('微信桥接未启动,请先在 TUI 执行 /wechat start') } if (!lastWechatUser) return { allowed: false, error: fail('尚未收到微信用户消息,无法获取 context_token。请先让微信用户发送一条消息。') } - const cwd = latestCtx?.cwd ?? process.cwd() - const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath) - - if (!isPathInCwd(resolvedPath, cwd)) { - return { - allowed: false, - error: fail(`安全限制:只能发送项目目录内的文件。\n路径: ${resolvedPath}\n项目: ${path.resolve(cwd)}`), - } - } - if (!existsSync(resolvedPath)) return { allowed: false, error: fail(`文件不存在: ${resolvedPath}`) } - - return { allowed: true, resolvedPath, cwd } + const result = await validateProjectFilePath(filePath, latestCtx?.cwd ?? process.cwd()) + if (!result.allowed) return { allowed: false, error: fail(result.reason) } + return result } function guardFileSize(resolvedPath: string): ReturnType | null { @@ -255,12 +235,16 @@ export default function wechatAssistant(pi: ExtensionAPI) { // --- 单条消息处理 --- async function handleIncomingMessage(message: IncomingMessage, activeClient: WeixinClient): Promise { + // 客户端已有同样的检查;这里保留边界防御,确保未来调用路径也不会绕过授权。 + if (!isAuthorizedWeChatSender(message.raw.from_user_id, activeClient.userId)) { + log(`丢弃未授权微信消息: from=${message.raw.from_user_id || '(empty)'}`) + return + } log(`收到消息: type=${message.type}, text=${message.text?.slice(0, 50)}, images=${message.imageUrls.length}`) if (UNSUPPORTED_TYPES.has(message.type)) { const reply = UNSUPPORTED_REPLY[message.type] ?? UNSUPPORTED_REPLY['unknown'] try { - activeClient.rememberContext(message.raw) await activeClient.sendText(message.userId, reply) } catch (err) { log(`回复不支持类型消息失败: ${formatError(err)}`) @@ -269,7 +253,6 @@ export default function wechatAssistant(pi: ExtensionAPI) { } if (message.text.startsWith('/')) { - activeClient.rememberContext(message.raw) const handled = await handleRemoteCommand(message.text, message.userId, activeClient, remoteCommandDeps) if (handled) return } @@ -330,7 +313,7 @@ export default function wechatAssistant(pi: ExtensionAPI) { fileName: Type.Optional(Type.String({ description: '在微信中显示的文件名(可选,默认使用原文件名)' })), }), async execute(_toolCallId, params, _signal) { - const guard = guardSendToWechat(client, running, queue.lastWechatUser, params.filePath, latestCtx) + const guard = await guardSendToWechat(client, running, queue.lastWechatUser, params.filePath, latestCtx) if (!guard.allowed) return guard.error const sizeError = guardFileSize(guard.resolvedPath) if (sizeError) return sizeError @@ -361,7 +344,7 @@ export default function wechatAssistant(pi: ExtensionAPI) { imagePath: Type.String({ description: '要发送的图片路径(项目目录内的绝对路径或相对路径,支持 png/jpg/gif/webp)' }), }), async execute(_toolCallId, params, _signal) { - const guard = guardSendToWechat(client, running, queue.lastWechatUser, params.imagePath, latestCtx) + const guard = await guardSendToWechat(client, running, queue.lastWechatUser, params.imagePath, latestCtx) if (!guard.allowed) return guard.error const sizeError = guardFileSize(guard.resolvedPath) if (sizeError) return sizeError @@ -433,7 +416,6 @@ export default function wechatAssistant(pi: ExtensionAPI) { latestCtx = ctx agentIdle = false turn.sentCount = 0 - turn.messages = null turn.ended = false if (queue.pendingInjection) { @@ -459,7 +441,7 @@ export default function wechatAssistant(pi: ExtensionAPI) { return } - const text = extractTextFromMessageContent(event.message.content) + const text = getAssistantTextFromMessageEnd(event.message) if (!text) { log(`[MSG-END-SKIP] no text content (likely toolCall only)`) return @@ -480,34 +462,13 @@ export default function wechatAssistant(pi: ExtensionAPI) { } }) - // agent 结束 → 补发遗漏 + 收尾 + // agent 结束 → 仅收尾。回复只能在本 turn 的 message_end 中发送, + // 绝不回扫 event.messages,避免恢复 session 后重发历史消息。 pi.on('agent_end', async (event, ctx) => { latestCtx = ctx agentIdle = true turn.ended = true - turn.messages = event.messages as Array<{ role?: string; content?: unknown }> - - const msgCount = turn.messages.length - const assistantMsgs = turn.messages.filter(m => m?.role === 'assistant').length - log(`[AGENT-END] turn#${turn.seq} source=${turn.wechatConversationActive ? 'WECHAT' : 'TUI'} targetUser=${turn.targetUser} messages=${msgCount} assistant=${assistantMsgs} sentCount=${turn.sentCount}`) - - const allReplies = extractAllAssistantReplies(turn.messages) - const newReplies = allReplies.slice(turn.sentCount) - log(`[AGENT-END-REPLIES] all=${allReplies.length} sent=${turn.sentCount} new=${newReplies.length}`) - - if (turn.wechatConversationActive && newReplies.length > 0 && client && turn.targetUser) { - try { - await queue.sendRepliesToWechat(newReplies, turn.targetUser) - log(`[AGENT-END-DONE] sent ${newReplies.length} remaining replies`) - } catch (err) { - log(`[AGENT-END-ERROR] ${formatError(err)}`) - notify(`发送微信回复失败: ${formatError(err)}`, 'error') - } - } else if (allReplies.length === 0) { - log(`[AGENT-END-NOREPLY] no assistant text`) - } else { - log(`[AGENT-END-SAFE] all replies already sent incrementally`) - } + log(`[AGENT-END] turn#${turn.seq} source=${turn.wechatConversationActive ? 'WECHAT' : 'TUI'} targetUser=${turn.targetUser} messageEndSent=${turn.sentCount}; no history replay`) if (queue.activeRequest) { await client?.stopTyping(queue.activeRequest.userId).catch(() => {}) diff --git a/src/remote-commands.ts b/src/remote-commands.ts index 75e39ea..deeca1d 100644 --- a/src/remote-commands.ts +++ b/src/remote-commands.ts @@ -7,6 +7,7 @@ import { debugLog } from './logger.js' import { formatError } from './utils.js' import { getImageMaxBytes, getImageBatchWaitMs } from './queue.js' import { WeixinClient } from './client.js' +import { areRemoteToolsAllowed } from './security.js' type Ctx = ExtensionContext | ExtensionCommandContext @@ -65,6 +66,9 @@ const commands: Record = { }, async tools(args, _userId, _client, deps) { + if (!areRemoteToolsAllowed()) { + return '🔒 出于安全考虑,微信端 /tools 默认禁用,不会更改本地工具权限。仅可在 Ubuntu 本机设置 PI_WECHAT_ALLOW_REMOTE_TOOLS=1 后启用。' + } if (!args) { const active = deps.pi.getActiveTools() const all = deps.pi.getAllTools().map(t => t.name) @@ -252,7 +256,7 @@ const commands: Record = { '/config 查看图片相关配置', '/help 显示帮助', '', - '高级: /thinking, /tools, /compact', + '高级: /thinking, /compact;/tools 默认禁用(仅 Ubuntu 本机设置 PI_WECHAT_ALLOW_REMOTE_TOOLS=1 后启用)', '直接发文字、语音、图片、文件 = 正常对话', ].join('\n') }, diff --git a/src/security.ts b/src/security.ts new file mode 100644 index 0000000..7bbf872 --- /dev/null +++ b/src/security.ts @@ -0,0 +1,130 @@ +// ============================================================================ +// 微信桥接安全边界:身份、重放、文件路径和远程工具开关 +// ============================================================================ + +import { readFileSync } from 'node:fs' +import { lstat, realpath, stat } from 'node:fs/promises' +import * as path from 'node:path' + +export const MAX_RECENT_MESSAGE_IDS = 500 + +/** 仅允许扫码凭证所绑定的微信用户进入桥接。 */ +export function isAuthorizedWeChatSender(senderId: string | undefined, credentialUserId: string): boolean { + return Boolean(credentialUserId) && senderId === credentialUserId +} + +/** + * 将消息 ID 加入一个有界的最近处理窗口。返回新数组,不修改调用方数据。 + * 空 ID 不参与去重,以避免把协议异常消息错误地全部折叠为同一条。 + */ +export function recordProcessedMessageId( + recentIds: readonly string[], + messageId: string, + limit = MAX_RECENT_MESSAGE_IDS, +): string[] { + if (!messageId) return [...recentIds].slice(-limit) + const withoutCurrent = recentIds.filter(id => id !== messageId) + return [...withoutCurrent, messageId].slice(-limit) +} + +export function isDuplicateMessageId(recentIds: ReadonlySet, messageId: string): boolean { + return Boolean(messageId) && recentIds.has(messageId) +} + +/** 仅从当前 message_end 事件取可发送的 assistant 文本,不接收 session 历史数组。 */ +export function getAssistantTextFromMessageEnd(message: { role?: string; content?: unknown }): string | null { + if (message.role !== 'assistant') return null + if (typeof message.content === 'string') return message.content.trim() || null + if (!Array.isArray(message.content)) return null + const text = message.content + .filter((part): part is { type?: unknown; text?: unknown } => typeof part === 'object' && part !== null) + .filter((part): part is { type: 'text'; text: string } => part.type === 'text' && typeof part.text === 'string') + .map(part => part.text.trim()) + .filter(Boolean) + .join('\n') + return text || null +} + +/** 使用 relative 而不是前缀匹配,避免 /project-other 这类路径绕过。 */ +export function isPathWithin(parentPath: string, targetPath: string): boolean { + const relative = path.relative(parentPath, targetPath) + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) +} + +export type ProjectFilePathResult = + | { allowed: true; resolvedPath: string; cwd: string } + | { allowed: false; reason: string } + +/** + * 验证待发送文件:必须是项目真实目录内的普通文件,且输入路径的任意项目内段都不能是符号链接。 + */ +export async function validateProjectFilePath(filePath: string, cwd: string): Promise { + const lexicalCwd = path.resolve(cwd) + const candidate = path.resolve(lexicalCwd, filePath) + if (!isPathWithin(lexicalCwd, candidate)) { + return { allowed: false, reason: `安全限制:只能发送项目目录内的文件。\n路径: ${candidate}\n项目: ${lexicalCwd}` } + } + + let realCwd: string + try { + realCwd = await realpath(lexicalCwd) + } catch { + return { allowed: false, reason: `无法解析项目目录: ${lexicalCwd}` } + } + + // 拒绝路径中任何项目内符号链接,而不仅是最后一个文件名。 + const segments = path.relative(lexicalCwd, candidate).split(path.sep).filter(Boolean) + let current = lexicalCwd + try { + for (const segment of segments) { + current = path.join(current, segment) + if ((await lstat(current)).isSymbolicLink()) { + return { allowed: false, reason: `安全限制:不允许通过符号链接发送文件: ${current}` } + } + } + } catch { + return { allowed: false, reason: `文件不存在或无法读取: ${candidate}` } + } + + let realTarget: string + try { + realTarget = await realpath(candidate) + } catch { + return { allowed: false, reason: `文件不存在或无法解析: ${candidate}` } + } + if (!isPathWithin(realCwd, realTarget)) { + return { allowed: false, reason: `安全限制:真实文件路径不在项目目录内。\n路径: ${realTarget}\n项目: ${realCwd}` } + } + + try { + if (!(await stat(realTarget)).isFile()) { + return { allowed: false, reason: `安全限制:只能发送普通文件: ${realTarget}` } + } + } catch { + return { allowed: false, reason: `无法读取文件: ${realTarget}` } + } + + return { allowed: true, resolvedPath: realTarget, cwd: realCwd } +} + +export interface RemoteToolsEnvironment { + env?: NodeJS.ProcessEnv + platform?: NodeJS.Platform + osRelease?: string +} + +/** 远程修改工具集仅在 Ubuntu 本机显式开启时可用。 */ +export function areRemoteToolsAllowed(options: RemoteToolsEnvironment = {}): boolean { + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const osRelease = options.osRelease ?? readHostOsRelease() + return platform === 'linux' && /ubuntu/i.test(osRelease) && env.PI_WECHAT_ALLOW_REMOTE_TOOLS === '1' +} + +function readHostOsRelease(): string { + try { + return readFileSync('/etc/os-release', 'utf8') + } catch { + return '' + } +} diff --git a/tests/client-security.test.ts b/tests/client-security.test.ts new file mode 100644 index 0000000..9fdd0af --- /dev/null +++ b/tests/client-security.test.ts @@ -0,0 +1,82 @@ +// ============================================================================ +// 测试: 客户端入口必须在缓存 context token 前完成授权和去重 +// ============================================================================ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + apiGetUpdates: vi.fn(), + saveContextTokensThrottled: vi.fn(), + saveTransportState: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../src/api.js', () => ({ + getUpdates: mocks.apiGetUpdates, + getConfig: vi.fn(), + sendMessage: vi.fn(), + sendTyping: vi.fn(), + getUploadUrl: vi.fn(), + uploadToCdn: vi.fn(), + sendMediaMessage: vi.fn(), + isSessionExpired: vi.fn(() => false), +})) + +vi.mock('../src/auth.js', () => ({ + loadContextTokens: vi.fn().mockResolvedValue({ lastUserId: null, tokens: {} }), + saveContextTokensThrottled: mocks.saveContextTokensThrottled, + flushContextTokens: vi.fn().mockResolvedValue(undefined), + loadTransportState: vi.fn().mockResolvedValue({ cursor: '', processedMessageIds: [] }), + saveTransportState: mocks.saveTransportState, +})) + +import { WeixinClient } from '../src/client.js' + +const credentials = { + token: 'token', + baseUrl: 'https://example.test', + accountId: 'bot', + userId: 'bound-user', +} + +function rawMessage(messageId: string, fromUserId: string) { + return { + message_type: 1, + message_id: messageId, + from_user_id: fromUserId, + context_token: `context-${messageId}`, + create_time_ms: Date.now(), + item_list: [{ type: 1, text_item: { text: 'hello' } }], + } +} + +describe('WeixinClient security gate', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.saveTransportState.mockResolvedValue(undefined) + }) + + it('drops an unbound sender before it stores a context token or produces an incoming message', async () => { + mocks.apiGetUpdates.mockResolvedValueOnce({ + get_updates_buf: 'cursor-1', + msgs: [rawMessage('attacker-message', 'attacker')], + }) + const client = await WeixinClient.create(credentials) + + await expect(client.getUpdates()).resolves.toEqual([]) + expect(mocks.saveContextTokensThrottled).not.toHaveBeenCalled() + expect(client.lastActiveUserId).toBeNull() + expect(mocks.saveTransportState).toHaveBeenCalledWith({ cursor: 'cursor-1', processedMessageIds: [] }) + }) + + it('skips a processed message ID after restart-safe transport state is recorded', async () => { + const message = rawMessage('same-message', 'bound-user') + mocks.apiGetUpdates + .mockResolvedValueOnce({ get_updates_buf: 'cursor-1', msgs: [message] }) + .mockResolvedValueOnce({ get_updates_buf: 'cursor-2', msgs: [message] }) + const client = await WeixinClient.create(credentials) + + await expect(client.getUpdates()).resolves.toHaveLength(1) + await expect(client.getUpdates()).resolves.toEqual([]) + expect(mocks.saveTransportState).toHaveBeenLastCalledWith({ cursor: 'cursor-2', processedMessageIds: ['same-message'] }) + }) +}) diff --git a/tests/remote-commands.test.ts b/tests/remote-commands.test.ts new file mode 100644 index 0000000..7d98295 --- /dev/null +++ b/tests/remote-commands.test.ts @@ -0,0 +1,48 @@ +// ============================================================================ +// 测试: 微信远程命令安全限制 +// ============================================================================ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { handleRemoteCommand } from '../src/remote-commands.js' + +const originalRemoteTools = process.env.PI_WECHAT_ALLOW_REMOTE_TOOLS + +afterEach(() => { + if (originalRemoteTools === undefined) delete process.env.PI_WECHAT_ALLOW_REMOTE_TOOLS + else process.env.PI_WECHAT_ALLOW_REMOTE_TOOLS = originalRemoteTools +}) + +function makeDeps() { + return { + pi: { + getActiveTools: vi.fn(() => ['read']), + getAllTools: vi.fn(() => [{ name: 'read' }, { name: 'shell' }]), + setActiveTools: vi.fn(), + }, + getCtx: vi.fn(() => null), + client: vi.fn(() => null), + queueLength: vi.fn(() => 0), + } +} + +describe('WeChat /tools', () => { + it('is disabled by default and never calls setActiveTools', async () => { + delete process.env.PI_WECHAT_ALLOW_REMOTE_TOOLS + const deps = makeDeps() + const client = { sendText: vi.fn().mockResolvedValue(undefined) } + + await expect(handleRemoteCommand('/tools shell', 'bound-user', client as any, deps as any)).resolves.toBe(true) + + expect(deps.pi.setActiveTools).not.toHaveBeenCalled() + expect(client.sendText).toHaveBeenCalledWith('bound-user', expect.stringContaining('/tools 默认禁用')) + }) + + it('/help documents that /tools is disabled by default', async () => { + const deps = makeDeps() + const client = { sendText: vi.fn().mockResolvedValue(undefined) } + + await handleRemoteCommand('/help', 'bound-user', client as any, deps as any) + + expect(client.sendText).toHaveBeenCalledWith('bound-user', expect.stringContaining('/tools 默认禁用')) + }) +}) diff --git a/tests/security.test.ts b/tests/security.test.ts new file mode 100644 index 0000000..a3cc0f0 --- /dev/null +++ b/tests/security.test.ts @@ -0,0 +1,113 @@ +// ============================================================================ +// 测试: 安全边界纯函数 +// ============================================================================ + +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + areRemoteToolsAllowed, + getAssistantTextFromMessageEnd, + isAuthorizedWeChatSender, + isDuplicateMessageId, + MAX_RECENT_MESSAGE_IDS, + recordProcessedMessageId, + validateProjectFilePath, +} from '../src/security.js' + +describe('微信用户绑定', () => { + it('only accepts the user ID from QR-login credentials', () => { + expect(isAuthorizedWeChatSender('bound-user', 'bound-user')).toBe(true) + expect(isAuthorizedWeChatSender('other-user', 'bound-user')).toBe(false) + expect(isAuthorizedWeChatSender(undefined, 'bound-user')).toBe(false) + expect(isAuthorizedWeChatSender('bound-user', '')).toBe(false) + }) +}) + +describe('消息重放窗口', () => { + it('identifies processed message IDs and caps the retained IDs at 500', () => { + let ids: string[] = [] + for (let i = 0; i < MAX_RECENT_MESSAGE_IDS + 5; i++) ids = recordProcessedMessageId(ids, `message-${i}`) + + expect(ids).toHaveLength(MAX_RECENT_MESSAGE_IDS) + expect(ids[0]).toBe('message-5') + expect(ids.at(-1)).toBe(`message-${MAX_RECENT_MESSAGE_IDS + 4}`) + expect(isDuplicateMessageId(new Set(ids), 'message-5')).toBe(true) + expect(isDuplicateMessageId(new Set(ids), 'message-0')).toBe(false) + }) +}) + +describe('current-turn assistant reply selection', () => { + it('uses only message_end messages, supports multiple replies and skips tool calls', () => { + const restoredHistory = [ + { role: 'assistant', content: 'old reply must not be sent again' }, + { role: 'assistant', content: 'another old reply' }, + ] + const currentMessageEndEvents = [ + { role: 'assistant', content: [{ type: 'toolCall', name: 'write_file' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'new reply one' }, { type: 'toolCall', name: 'send_file' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'new reply two' }] }, + ] + + const sent = currentMessageEndEvents.map(getAssistantTextFromMessageEnd).filter((text): text is string => text !== null) + expect(sent).toEqual(['new reply one', 'new reply two']) + expect(restoredHistory.map(getAssistantTextFromMessageEnd)).toEqual([ + 'old reply must not be sent again', + 'another old reply', + ]) + // The bridge intentionally never passes restoredHistory to the sender; only the event list above is eligible. + }) +}) + +describe('send-to-WeChat path validation', () => { + const tempDirs: string[] = [] + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) + }) + + it('allows a normal project file', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'pi-wechat-security-')) + tempDirs.push(root) + const project = path.join(root, 'project') + await mkdir(project) + await writeFile(path.join(project, 'report.txt'), 'safe') + + const result = await validateProjectFilePath('report.txt', project) + expect(result.allowed).toBe(true) + if (result.allowed) expect(path.basename(result.resolvedPath)).toBe('report.txt') + }) + + it('rejects a file outside the project', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'pi-wechat-security-')) + tempDirs.push(root) + const project = path.join(root, 'project') + const outside = path.join(root, 'secret.txt') + await mkdir(project) + await writeFile(outside, 'secret') + + await expect(validateProjectFilePath(outside, project)).resolves.toMatchObject({ allowed: false }) + }) + + it('rejects an in-project symlink that points outside the project', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'pi-wechat-security-')) + tempDirs.push(root) + const project = path.join(root, 'project') + const outside = path.join(root, 'secret.txt') + await mkdir(project) + await writeFile(outside, 'secret') + await symlink(outside, path.join(project, 'linked-secret.txt')) + + await expect(validateProjectFilePath('linked-secret.txt', project)).resolves.toMatchObject({ allowed: false }) + }) +}) + +describe('remote /tools opt-in', () => { + it('requires Ubuntu, Linux, and an explicit environment opt-in', () => { + expect(areRemoteToolsAllowed({ platform: 'linux', osRelease: 'NAME="Ubuntu"', env: { PI_WECHAT_ALLOW_REMOTE_TOOLS: '1' } })).toBe(true) + expect(areRemoteToolsAllowed({ platform: 'darwin', osRelease: 'NAME="Ubuntu"', env: { PI_WECHAT_ALLOW_REMOTE_TOOLS: '1' } })).toBe(false) + expect(areRemoteToolsAllowed({ platform: 'linux', osRelease: 'NAME="Debian"', env: { PI_WECHAT_ALLOW_REMOTE_TOOLS: '1' } })).toBe(false) + expect(areRemoteToolsAllowed({ platform: 'linux', osRelease: 'NAME="Ubuntu"', env: {} })).toBe(false) + }) +})