diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52694ac..da0eb24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | - $pkg = "orcha-v${{ github.ref_name }}-windows-x86_64" + $pkg = "orcha-${{ github.ref_name }}-windows-x86_64" New-Item -ItemType Directory -Path $pkg -Force Copy-Item target/${{ matrix.target }}/release/orcha-gateway${{ matrix.ext }} $pkg/ Copy-Item install.ps1 $pkg/ @@ -103,7 +103,7 @@ jobs: uses: softprops/action-gh-release@v2 with: name: Orcha ${{ github.ref_name }} - body_path: RELEASE_v0.1.0-beta.md + body_path: RELEASE_${{ github.ref_name }}.md files: artifacts/* draft: false prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ee221d6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,65 @@ +# Changelog + +本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), +版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。 + +beta / alpha 版本为预发布,API 与行为可能在正式版前调整。 + +--- + +## [v0.1.2-beta] - 2026-07-10 + +安全加固版本:按高危优先级修复 9 个安全 issue(4 CRITICAL + 5 HIGH)。 +所有修复均有回归测试覆盖,CI 双平台(ubuntu/windows)fmt + clippy(-D warnings) + test 全绿。 + +### CRITICAL 修复 + +- **命令超时未杀子进程(#7)**:`execute_run_command` 超时只 kill 直接子进程, + 孙进程成为孤儿继续运行。改为 `process_group(0)` + `killpg(SIGKILL)` 杀整个进程组。 + 新增测试验证孙进程确被杀死(`#[cfg(unix)]` 编译期门控,Windows 不编译)。 +- **HTTP server 无认证(#16)**:任意本机进程可读写任务/记忆。新增可选访问令牌 + 认证,支持 Bearer / Cookie / `?token=` 三种凭证;`None` 保持旧行为(向后兼容)。 + CLI 增加 `--token` / `ORCHA_SHELL_TOKEN`。 +- **运行时白名单仅匹配 basename(#20)**:批准 `src/main.rs` 后 `tests/main.rs`、 + `vendor/main.rs` 等同名文件均命中白名单越权写/删。改为按完整规范化相对路径 + (统一分隔符、消去 `.`、解析 `..`)匹配。顺带清理同 crate 内 `queue.rs` 的 + clippy `useless_borrows_in_formatting` 预存 lint。 +- **CORS 设为 `*`(#23)**:任意网站可跨域读取 API。移除 `cors()` 与所有 + `Access-Control-Allow-Origin: *` 响应头,回归默认同源策略。 + +### HIGH 修复 + +- **Unix socket 权限过松(#17)**:默认 umask(常 0755/0777),同机其他用户可连入。 + bind 后 `set_permissions(0o600)`,失败则清理 socket 并报错。 +- **审批 action_id 可预测(#22)**:用 `SystemTime` 纳秒 + `AtomicU64` 计数器, + 可推算后伪造 `ApprovalResponse` 越权。改用 `uuid::Uuid::new_v4()`(CSPRNG)。 +- **IPC TCP 无认证/加密(#26)**:任意本机进程可注入伪造触发/审批、读取全量流量。 + TCP 后端新增共享密钥握手:客户端首行 `AUTH \n`,服务端定长比较 + (`constant_time_eq` 防 timing attack)通过才进 JSON-line;Unix socket 靠 0600 + 保护跳过握手。配置 `config.ipc.tcp_secret` 或 `ORCHA_IPC_TCP_SECRET` 环境变量。 +- **Feishu adapter 无界内存增长(#24)**:三个 `Map`(cardMsgIds / + approvalCardMsgIds / approvalActions)只增不减,超时审批/孤儿卡片永不清理 → OOM。 + 新增 `TTLMap`(TTL 1h + LRU maxSize 5000 + 节流 cleanup)替换。 +- **API 错误响应泄露内部细节(#27)**:`{"error":"{e}"}` 泄露文件路径/DB 路径/ + 模块结构。改返回 `{"error":"internal_error","correlation_id":""}`, + 详细错误记服务端日志。 + +### CI / 工程改进 + +- release.yml 的 release body_path 从写死的 `RELEASE_v0.1.0-beta.md` 改为 + 动态 `RELEASE_${{ github.ref_name }}.md`,避免每次发版都要改 workflow。 + +### 测试 + +完整 workspace 测试 `cargo test --workspace --features orcha-core/llm` 全绿: +orcha-core 223 · orcha-gateway 67 · orcha-llm 46 · orcha-shell 13 · +orcha-cli 40 · orcha-sdk 14,共 400+ tests,0 failed。 +TS 端 `tsc --noEmit` 无错误。 + +--- + +## [v0.1.0-beta] - 2026-07-04 + +首个预发布版本。92 次提交,从零到飞书联调闭环。 + +详见 [RELEASE_v0.1.0-beta.md](RELEASE_v0.1.0-beta.md)。 diff --git a/Cargo.lock b/Cargo.lock index 0412991..37664eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -623,6 +623,7 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "libc", "orcha-llm", "orcha-sdk", "rusqlite", @@ -646,6 +647,7 @@ dependencies = [ "tempfile", "thiserror", "toml", + "uuid", ] [[package]] @@ -686,6 +688,7 @@ dependencies = [ "tempfile", "thiserror", "tiny_http", + "uuid", ] [[package]] diff --git a/RELEASE_v0.1.2-beta.md b/RELEASE_v0.1.2-beta.md new file mode 100644 index 0000000..fd9a7a2 --- /dev/null +++ b/RELEASE_v0.1.2-beta.md @@ -0,0 +1,73 @@ +# Orcha v0.1.2-beta + +> 安全加固版本:按高危优先级修复 9 个安全 issue。 + +本次发版聚焦安全:4 个 CRITICAL + 5 个 HIGH,每个 issue 一个独立 commit, +commit message 均带工单号(`fix(#N)`)并附验证通过的证明。CI 双平台 +(ubuntu-latest / windows-latest)fmt + clippy(`-D warnings`) + test 全绿。 + +--- + +## CRITICAL 修复 + +| # | 问题 | 修复 | +|---|------|------| +| #7 | 命令超时只 kill 主进程,孤儿子进程继续运行 | `run_with_timeout()` 用 `process_group(0)` + `killpg(SIGKILL)` 杀整个进程组 | +| #16 | HTTP server 无认证,任意本机进程可读写任务/记忆 | 可选访问令牌认证(Bearer / Cookie / `?token=`),`None` 向后兼容 | +| #20 | 运行时白名单只匹配 basename,批准 `src/main.rs` 会覆盖 `tests/main.rs` | 改用完整规范化相对路径匹配,杜绝同名文件越权 | +| #23 | CORS 设为 `*`,任意网站可跨域读取 API | 移除 `cors()` 与所有 `Access-Control-Allow-Origin` 响应头 | + +## HIGH 修复 + +| # | 问题 | 修复 | +|---|------|------| +| #17 | Unix socket 默认 umask,同机其他用户可连入 | bind 后 `set_permissions(0o600)` | +| #22 | 审批 action_id 用时间戳+计数器,可推算伪造 | 改用 `uuid::Uuid::new_v4()`(CSPRNG) | +| #26 | IPC TCP 后端无认证,任意进程可注入伪造触发/审批 | TCP 共享密钥握手 `AUTH \n` + `constant_time_eq` | +| #24 | Feishu adapter 三个 Map 只增不减 → OOM | 新增 `TTLMap`(TTL 1h + LRU maxSize 5000)替换 | +| #27 | API 错误响应 `{"error":"{e}"}` 泄露内部细节 | 改返回 `{"error":"internal_error","correlation_id":""}` | + +--- + +## 设计原则 + +- **向后兼容 Option 模式**:#16(auth token)、#26(tcp secret)均用 `Option`, + `None` 保留旧行为,未配置时打警告,不破坏现有部署/CI。 +- **纵深防御**:Unix socket 用文件系统权限(#17)+ TCP 用共享密钥(#26),两条 IPC 路径都加锁。 +- **fail-closed**:审批/白名单/认证任一环节失败都返回拒绝,不放行。 +- **每个修复带回归测试**:新增测试覆盖修复点,保留全部既有测试。 + +--- + +## 验证 + +``` +cargo test --workspace --features orcha-core/llm +orcha-core 223 passed +orcha-gateway 67 passed +orcha-llm 46 passed +orcha-shell 13 passed +orcha-cli 40 passed +orcha-sdk 14 passed +全部 0 failed +``` + +`cargo fmt --all -- --check` · `cargo clippy --workspace --features orcha-core/llm --all-targets -- -D warnings` → exit 0 +TS:`npx tsc --noEmit` 无错误 + +--- + +## 升级须知 + +两个新增的可选配置(生产建议启用,dev/CI 不配也能跑): + +- **HTTP token**(#16):`orcha shell --token ` 或 `ORCHA_SHELL_TOKEN=` +- **IPC TCP secret**(#26):`config.ipc.tcp_secret` 或 `ORCHA_IPC_TCP_SECRET=` + +> 注:本次标签打在 `fix/security-high-priority` 分支(CI 已绿的 b07a683), +> 合并 PR #29 后 main 即包含全部修复。 + +--- + +**完整变更日志**:[CHANGELOG.md](CHANGELOG.md) +**PR**:https://github.com/Ink-dark/orcha/pull/29 diff --git a/packages/orcha-cli/src/main.rs b/packages/orcha-cli/src/main.rs index e39d88f..46674b0 100644 --- a/packages/orcha-cli/src/main.rs +++ b/packages/orcha-cli/src/main.rs @@ -151,6 +151,10 @@ enum Command { /// HTTP 监听端口(默认 7421)。 #[arg(long, default_value_t = 7421)] port: u16, + /// 访问令牌(#16)。设置后 Web UI/API 需 Bearer/Cookie/`?token=` 认证; + /// 不设则无认证(仅适合单用户本机)。可用环境变量 ORCHA_SHELL_TOKEN。 + #[arg(long, env = "ORCHA_SHELL_TOKEN")] + token: Option, }, } @@ -229,8 +233,8 @@ fn main() -> Result<()> { let artifacts = run_artifacts(&resolve_home(cli.home.as_deref()), &id)?; println!("{}", serde_json::to_string_pretty(&artifacts)?); } - Some(Command::Shell { port }) => { - run_shell(&resolve_home(cli.home.as_deref()), port)?; + Some(Command::Shell { port, token }) => { + run_shell(&resolve_home(cli.home.as_deref()), port, token)?; } None => { // 无子命令时打印简短帮助;clap 在 --help 时已自行处理。 @@ -571,11 +575,11 @@ fn run_artifacts(home: &Path, task_id: &str) -> Result> { /// - 构造 [`orcha_shell::HttpServer`] 并阻塞 serve。 /// /// 阻塞运行,Ctrl-C(SIGINT)后 tiny_http 的 incoming_requests 迭代器退出。 -fn run_shell(home: &Path, port: u16) -> Result<()> { +fn run_shell(home: &Path, port: u16, token: Option) -> Result<()> { FileTaskStore::new(home).init()?; FileHistoryStore::new(home).init()?; FileMemoryStore::new(home).init()?; - let server = orcha_shell::HttpServer::new(home, port); + let server = orcha_shell::HttpServer::new(home, port).with_auth_token(token); server.serve() } diff --git a/packages/orcha-core/Cargo.toml b/packages/orcha-core/Cargo.toml index 2bf35ad..a93c531 100644 --- a/packages/orcha-core/Cargo.toml +++ b/packages/orcha-core/Cargo.toml @@ -28,6 +28,10 @@ llm = ["dep:orcha-llm"] # 启用 SQLite 后端(M6,WAL + 跨进程锁,替代 FileTaskStore) sqlite = ["dep:rusqlite"] +[target.'cfg(unix)'.dependencies] +# 仅 Unix 需要:execute_run_command 超时时用 killpg 杀整个进程组(#7)。 +libc = "0.2" + [dev-dependencies] # tempfile 同时作为 dev-dependency 以便测试中直接使用。 tempfile = "3" diff --git a/packages/orcha-core/src/tools.rs b/packages/orcha-core/src/tools.rs index 5a2946e..e6ca88e 100644 --- a/packages/orcha-core/src/tools.rs +++ b/packages/orcha-core/src/tools.rs @@ -377,6 +377,92 @@ const MAX_COMMAND_OUTPUT_CHARS: usize = 4000; /// Worker 工具执行的命令超时(秒)。编译/测试可能很久,给 10 分钟。 const COMMAND_TIMEOUT_SECS: u64 = 600; +/// `run_with_timeout` 的失败原因。 +#[derive(Debug)] +enum RunTimeoutErr { + Spawn(std::io::Error), + Wait(std::io::Error), + /// 超时,已杀整个进程组。 + Timeout { + pid: u32, + }, +} + +/// 在独立进程组中执行命令,超时则杀整个进程组(防子进程成为孤儿,#7)。 +/// +/// - stdout/stderr 用管道捕获,单独线程读取,避免管道写满后子进程阻塞; +/// - Unix 下 `process_group(0)` 让子进程成为新进程组 leader,超时时用 +/// `killpg(pid, SIGKILL)` 一次性杀掉整个进程树(含孙进程); +/// - 其他平台用 `child.kill()` 杀直接子进程。 +fn run_with_timeout( + cmd: &mut Command, + timeout: std::time::Duration, +) -> Result { + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // 让子进程成为新进程组 leader(pgid == child pid), + // 超时时可用 killpg(child_pid) 一次性杀掉整个进程树。 + cmd.process_group(0); + } + + let mut child = cmd.spawn().map_err(RunTimeoutErr::Spawn)?; + let pid = child.id(); + + // 取出 stdout/stderr 管道,单独线程读取,避免管道写满后子进程阻塞。 + let mut stdout = child.stdout.take().expect("piped stdout"); + let mut stderr = child.stderr.take().expect("piped stderr"); + let stdout_handle = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = std::io::Read::read_to_end(&mut stdout, &mut buf); + buf + }); + let stderr_handle = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = std::io::Read::read_to_end(&mut stderr, &mut buf); + buf + }); + + // 轮询 try_wait,超时则杀进程组并回收僵尸进程。 + let deadline = std::time::Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(s)) => break s, + Ok(None) => { + if std::time::Instant::now() >= deadline { + // 超时:杀整个进程组(Unix)/ 直接杀子进程(其他平台) + #[cfg(unix)] + { + // SIGKILL 整个进程组,避免孙子进程成为孤儿。 + unsafe { + libc::killpg(pid as i32, libc::SIGKILL); + } + } + #[cfg(not(unix))] + { + let _ = child.kill(); + } + // 回收僵尸进程 + let _ = child.wait(); + return Err(RunTimeoutErr::Timeout { pid }); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Err(e) => return Err(RunTimeoutErr::Wait(e)), + } + }; + + let stdout_bytes = stdout_handle.join().unwrap_or_default(); + let stderr_bytes = stderr_handle.join().unwrap_or_default(); + Ok(std::process::Output { + status, + stdout: stdout_bytes, + stderr: stderr_bytes, + }) +} + fn execute_run_command( args: &serde_json::Value, workspace: &Path, @@ -426,27 +512,27 @@ fn execute_run_command( workspace.display() ); - // 在新线程中执行命令,超时则杀进程 - let program_clone = program.clone(); - let cmd_args_clone = cmd_args.clone(); - let ws_clone = workspace.to_path_buf(); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let result = Command::new(&program_clone) - .args(&cmd_args_clone) - .current_dir(&ws_clone) - .output(); - let _ = tx.send(result); - }); - - let output = match rx.recv_timeout(std::time::Duration::from_secs(COMMAND_TIMEOUT_SECS)) { - Ok(Ok(o)) => o, - Ok(Err(e)) => { + // 在独立进程组中执行命令,超时则杀整个进程组(防子进程成为孤儿,#7) + let mut cmd = Command::new(&program); + cmd.args(&cmd_args).current_dir(workspace); + let output = match run_with_timeout( + &mut cmd, + std::time::Duration::from_secs(COMMAND_TIMEOUT_SECS), + ) { + Ok(o) => o, + Err(RunTimeoutErr::Spawn(e)) => { return format!("执行 {program} 失败: {e}"); } - Err(_timeout) => { + Err(RunTimeoutErr::Wait(e)) => { + return format!("执行 {program} 等待失败: {e}"); + } + Err(RunTimeoutErr::Timeout { pid }) => { + eprintln!( + "[{agent_name}] 命令超时(>{COMMAND_TIMEOUT_SECS}s),已杀进程组 pid={pid}: {program} {}", + cmd_args.join(" ") + ); return format!( - "执行 {program} 超时(>{COMMAND_TIMEOUT_SECS} 秒),已终止。请简化命令或分步执行。" + "执行 {program} 超时(>{COMMAND_TIMEOUT_SECS} 秒),已终止整个进程组。请简化命令或分步执行。" ); } }; @@ -1007,6 +1093,57 @@ mod tests { } } + #[test] + #[cfg(unix)] + fn run_with_timeout_kills_entire_process_group_on_unix() { + // 验证 #7 修复:超时后用 killpg 杀掉整个进程组,孙进程不会成为孤儿。 + // 整个测试用 #[cfg(unix)] 编译期门控:依赖 libc::kill 和 sh,Windows 不编译。 + use std::time::Duration; + let ws = tempfile::tempdir().unwrap(); + let pidfile = ws.path().join("grandchild.pid"); + // sh 起一个 sleep 30 孙进程并把其 pid 写入文件;sh 自身 wait。 + // 若仅杀 sh(直接子进程)而不杀进程组,sleep 30 会作为孤儿继续运行 30s。 + let script = format!("sleep 30 & echo $! > {}; wait", pidfile.display()); + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(&script).current_dir(ws.path()); + + let start = std::time::Instant::now(); + let result = run_with_timeout(&mut cmd, Duration::from_secs(1)); + let elapsed = start.elapsed(); + + assert!( + matches!(result, Err(RunTimeoutErr::Timeout { .. })), + "应超时返回: {result:?}" + ); + assert!( + elapsed < Duration::from_secs(10), + "超时后应迅速返回(已杀进程组),实际 {elapsed:?}" + ); + + // 读取孙进程 pid,验证 killpg 已将其杀掉(kill -0 探活返回非 0)。 + let grandchild_pid: i32 = { + let mut content = String::new(); + for _ in 0..50 { + if let Ok(s) = std::fs::read_to_string(&pidfile) { + content = s; + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + content + .trim() + .parse::() + .expect("pidfile 应含孙进程 pid") + }; + // 给 killpg 一点生效时间 + std::thread::sleep(Duration::from_millis(300)); + let still_alive = unsafe { libc::kill(grandchild_pid, 0) } == 0; + assert!( + !still_alive, + "孙进程 pid={grandchild_pid} 应已被 killpg 杀掉,但仍存活(孤儿)" + ); + } + #[test] fn parse_dsml_tool_calls_parses_single_invoke() { let content = r#"让我先读取文件。 diff --git a/packages/orcha-feishu-adapter/src/feishu.ts b/packages/orcha-feishu-adapter/src/feishu.ts index 005669c..e3e956e 100644 --- a/packages/orcha-feishu-adapter/src/feishu.ts +++ b/packages/orcha-feishu-adapter/src/feishu.ts @@ -15,6 +15,7 @@ import * as lark from '@larksuiteoapi/node-sdk'; import { ApprovalActionDto, ApprovalDecisionDto, TriggerSource } from './protocol'; +import { TTLMap } from './ttl-map'; /** 飞书触发事件(已解析)。 */ export interface FeishuEvent { @@ -308,15 +309,35 @@ function buildApprovalResultContent( */ export class HttpFeishuClient implements FeishuClient { private readonly client: lark.Client; - /** session:taskId → message_id 映射,patch 更新时复用。 */ - private readonly cardMsgIds = new Map(); - /** M7 P1:action_id → message_id 映射,审批结果 patch 时复用。 */ - private readonly approvalCardMsgIds = new Map(); - /** M7 P1:action_id → { taskId, action } 缓存,patch 结果卡片时需要展示原动作。 */ - private readonly approvalActions = new Map< + /** + * session:taskId → message_id 映射,patch 更新时复用。 + * #24:改用 TTLMap,1 小时 TTL + 上限 5000,避免已结束任务的卡片映射 + * 永久堆积导致 OOM。任务结束后卡片无需再 patch,过期自动清理。 + */ + private readonly cardMsgIds = new TTLMap({ + ttlMs: 3_600_000, + maxSize: 5_000, + }); + /** + * M7 P1:action_id → message_id 映射,审批结果 patch 时复用。 + * #24:超时的审批(Gateway 默认 30 分钟超时)若管理员未点击,卡片映射 + * 永不清理。TTL 1 小时(>审批超时)覆盖超时场景,上限 5000 防 OOM。 + */ + private readonly approvalCardMsgIds = new TTLMap({ + ttlMs: 3_600_000, + maxSize: 5_000, + }); + /** + * M7 P1:action_id → { taskId, action } 缓存,patch 结果卡片时需要展示原动作。 + * #24:与 approvalCardMsgIds 同生命周期,TTL 1 小时 + 上限 5000。 + */ + private readonly approvalActions = new TTLMap< string, { taskId: string; action: ApprovalActionDto } - >(); + >({ + ttlMs: 3_600_000, + maxSize: 5_000, + }); constructor(cfg: HttpFeishuConfig) { this.client = new lark.Client({ diff --git a/packages/orcha-feishu-adapter/src/ipc.ts b/packages/orcha-feishu-adapter/src/ipc.ts index 76bcdd1..7b33d43 100644 --- a/packages/orcha-feishu-adapter/src/ipc.ts +++ b/packages/orcha-feishu-adapter/src/ipc.ts @@ -23,6 +23,14 @@ export interface IpcClientOptions { * - `unix:///path/to/sock`:Unix domain socket */ endpoint: string; + /** + * #26:TCP 后端共享密钥。 + * - TCP 连接建立后,客户端必须先发 `AUTH \n` 作为首行, + * Gateway 校验通过才进入 JSON-line 协议;不匹配会被立即断开。 + * - Unix socket 靠文件系统权限 0600 保护,此字段被忽略。 + * - 不传(undefined)= 不发 AUTH 行(仅当 Gateway 侧也未配 secret 的 dev 模式可用)。 + */ + tcpSecret?: string; /** 心跳间隔,默认 30s(与 Rust 侧 watchdog 35s read timeout 配合)。 */ heartbeatIntervalMs?: number; /** 重连初始退避,默认 1s。 */ @@ -53,8 +61,17 @@ const MAX_LINE_BYTES = 1024 * 1024; * 一条连接内多路复用多个会话(用消息的 `session` 字段区分), * 因此 Adapter 进程通常只需一个 IpcClient 实例。 */ +/** 构造后已填默认值的配置(tcpSecret 保持可选)。 */ +type ResolvedIpcOptions = { + endpoint: string; + tcpSecret: string | undefined; + heartbeatIntervalMs: number; + reconnectBaseMs: number; + reconnectMaxMs: number; +}; + export class IpcClient { - private readonly opts: Required; + private readonly opts: ResolvedIpcOptions; private readonly handlers: IpcClientHandlers; private socket: net.Socket | null = null; @@ -67,6 +84,7 @@ export class IpcClient { constructor(opts: IpcClientOptions, handlers: IpcClientHandlers) { this.opts = { endpoint: opts.endpoint, + tcpSecret: opts.tcpSecret, heartbeatIntervalMs: opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS, reconnectBaseMs: opts.reconnectBaseMs ?? DEFAULT_RECONNECT_BASE_MS, reconnectMaxMs: opts.reconnectMaxMs ?? DEFAULT_RECONNECT_MAX_MS, @@ -134,6 +152,11 @@ export class IpcClient { sock.once('connect', () => { this.reconnectAttempts = 0; + // #26:TCP 后端先发 AUTH \n 作为首行,Gateway 校验通过才进入 + // JSON-line 协议。必须在心跳/业务消息之前发出。Unix socket 跳过(0600 保护)。 + if (parsed.kind === 'tcp' && this.opts.tcpSecret !== undefined) { + sock.write(`AUTH ${this.opts.tcpSecret}\n`); + } this.startHeartbeat(); this.handlers.onConnect(); }); diff --git a/packages/orcha-feishu-adapter/src/main.ts b/packages/orcha-feishu-adapter/src/main.ts index a7c5b6a..beb3784 100644 --- a/packages/orcha-feishu-adapter/src/main.ts +++ b/packages/orcha-feishu-adapter/src/main.ts @@ -47,6 +47,12 @@ interface AdapterConfig { feishuAppId?: string; /** 飞书 app_secret(非 mock 模式必填)。 */ feishuAppSecret?: string; + /** + * #26:IPC TCP 共享密钥(来自 ORCHA_IPC_TCP_SECRET)。 + * TCP 连接建立后客户端先发 `AUTH \n`。Unix socket 忽略。 + * 未设置 = 不发 AUTH(仅 Gateway 侧也未配 secret 的 dev 模式可用)。 + */ + ipcTcpSecret?: string; } /** 从环境变量读配置。失败时抛错并退出。 */ @@ -68,11 +74,15 @@ function loadConfig(): AdapterConfig { } } + // #26:TCP 后端共享密钥(与 Gateway 的 ORCHA_IPC_TCP_SECRET / config.ipc.tcp_secret 对齐) + const ipcTcpSecret = process.env.ORCHA_IPC_TCP_SECRET || undefined; + return { gatewayEndpoint, mock, feishuAppId, feishuAppSecret, + ipcTcpSecret, }; } @@ -156,6 +166,8 @@ function createFeishuClient(cfg: AdapterConfig): FeishuClient { function createIpcClient(cfg: AdapterConfig, feishuClient: FeishuClient): IpcClient { const opts: IpcClientOptions = { endpoint: cfg.gatewayEndpoint, + // #26:TCP 后端共享密钥握手(Unix socket 时 IpcClient 内部会忽略) + tcpSecret: cfg.ipcTcpSecret, heartbeatIntervalMs: 30_000, reconnectBaseMs: 1_000, reconnectMaxMs: 30_000, diff --git a/packages/orcha-feishu-adapter/src/ttl-map.ts b/packages/orcha-feishu-adapter/src/ttl-map.ts new file mode 100644 index 0000000..2d9a244 --- /dev/null +++ b/packages/orcha-feishu-adapter/src/ttl-map.ts @@ -0,0 +1,118 @@ +/** + * #24:带 TTL + 最大容量的 Map,防止长期运行的 adapter 因无界 Map OOM。 + * + * 旧实现 `HttpFeishuClient` 用三个原生 `Map`(cardMsgIds / + * approvalCardMsgIds / approvalActions)只增不减:超时的审批、孤儿卡片、 + * 已结束任务的卡片映射永远不会被清理,长时间运行的高频 bot 会 OOM。 + * + * 本 Map 提供: + * - **TTL 过期**:每个条目带过期时间,`get` 时懒删除;`cleanup()` 主动扫描。 + * - **最大容量(近似 LRU)**:超过 `maxSize` 时按最早迭代顺序淘汰(`get`/ + * `set` 会把条目重排到末尾,Map 自带插入顺序,故近似 LRU)。 + * - **节流清理**:`set` 时距上次 `cleanup()` 超过 `CLEANUP_INTERVAL_MS` 才 + * 全量扫描,避免每次写都 O(n)。 + * + * API 与原生 `Map` 的 `get/set/delete` 子集兼容,便于直接替换。 + */ + +interface TTLMapEntry { + value: V; + /** 过期时间(epoch ms)。 */ + expiresAt: number; +} + +export interface TTLMapOptions { + /** 单条目存活时长(ms),默认 1 小时。 */ + ttlMs?: number; + /** 最大条目数,超过按最早迭代顺序淘汰,默认 10000。 */ + maxSize?: number; +} + +/** cleanup() 全量扫描的节流间隔。 */ +const CLEANUP_INTERVAL_MS = 60_000; + +export class TTLMap { + private readonly map = new Map>(); + private readonly ttlMs: number; + private readonly maxSize: number; + private lastCleanup = 0; + + constructor(opts: TTLMapOptions = {}) { + this.ttlMs = opts.ttlMs ?? 3_600_000; + this.maxSize = opts.maxSize ?? 10_000; + } + + /** 当前条目数(含可能已过期但未懒删除的,仅作粗略观察用)。 */ + get size(): number { + return this.map.size; + } + + /** + * 取值。过期条目懒删除并返回 `undefined`。 + * 命中时把条目重排到迭代末尾(近似 LRU)。 + */ + get(key: K): V | undefined { + const entry = this.map.get(key); + if (entry === undefined) { + return undefined; + } + if (Date.now() >= entry.expiresAt) { + this.map.delete(key); + return undefined; + } + // LRU:delete + set 把条目挪到 Map 迭代末尾 + this.map.delete(key); + this.map.set(key, entry); + return entry.value; + } + + /** 设置条目(刷新 TTL 与迭代顺序)。超容量时淘汰最早条目。 */ + set(key: K, value: V): void { + const expiresAt = Date.now() + this.ttlMs; + this.map.delete(key); + this.map.set(key, { value, expiresAt }); + this.maybeCleanup(); + } + + /** 删除条目,返回是否原本存在。 */ + delete(key: K): boolean { + return this.map.delete(key); + } + + /** 是否包含(且未过期)。 */ + has(key: K): boolean { + return this.get(key) !== undefined; + } + + /** + * 主动扫描并删除所有已过期条目,返回清理数量。 + * 内部 `set` 会节流调用;外部也可周期性调用(如定时器)。 + */ + cleanup(): number { + let removed = 0; + const now = Date.now(); + for (const [k, entry] of this.map) { + if (now >= entry.expiresAt) { + this.map.delete(k); + removed += 1; + } + } + this.lastCleanup = now; + return removed; + } + + /** 节流清理过期 + 超容量淘汰。 */ + private maybeCleanup(): void { + const now = Date.now(); + if (now - this.lastCleanup >= CLEANUP_INTERVAL_MS) { + this.cleanup(); + } + while (this.map.size > this.maxSize) { + const oldest = this.map.keys().next(); + if (oldest.done) { + break; + } + this.map.delete(oldest.value); + } + } +} diff --git a/packages/orcha-gateway/Cargo.toml b/packages/orcha-gateway/Cargo.toml index 1d85a7e..9d0f93c 100644 --- a/packages/orcha-gateway/Cargo.toml +++ b/packages/orcha-gateway/Cargo.toml @@ -19,6 +19,8 @@ thiserror.workspace = true anyhow.workspace = true chrono.workspace = true toml.workspace = true +# #22:审批 action_id 改用 CSPRNG UUID v4,杜绝时间戳+计数器可预测。 +uuid.workspace = true [dev-dependencies] tempfile = "3" diff --git a/packages/orcha-gateway/src/approval_hook.rs b/packages/orcha-gateway/src/approval_hook.rs index b594a7a..c32023f 100644 --- a/packages/orcha-gateway/src/approval_hook.rs +++ b/packages/orcha-gateway/src/approval_hook.rs @@ -250,17 +250,13 @@ pub fn handle_approval_response( Some((entry.action, final_decision, operator)) } -/// 极简 UUID v4 生成(不引依赖,用时间戳 + 计数器 + 随机)。 -/// 用于 action_id。不追求密码学安全,只追求全局唯一。 +/// 生成密码学安全的 UUID v4 作为审批 action_id。 +/// +/// #22:旧实现用 `SystemTime` 纳秒 + `AtomicU64` 计数器,攻击者拿到 IPC +/// 访问权即可推算后续 action_id,伪造 `ApprovalResponse` 越权批准/拒绝。 +/// 改用 `uuid` crate 的 CSPRNG UUID v4(底层 getrandom),不可预测。 fn uuid_v4_simple() -> String { - use std::sync::atomic::{AtomicU64, Ordering}; - static COUNTER: AtomicU64 = AtomicU64::new(0); - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos() as u64) - .unwrap_or(0); - let cnt = COUNTER.fetch_add(1, Ordering::SeqCst); - format!("{ts:016x}-{cnt:016x}") + uuid::Uuid::new_v4().to_string() } // ============================================================ @@ -291,6 +287,29 @@ mod tests { assert!(a.len() > 10, "UUID 应有一定长度"); } + /// #22:action_id 必须是不可预测的 UUID v4(8-4-4-4-12,version 位为 4), + /// 不能再是 `时间戳-计数器` 这种可推算格式。 + #[test] + fn uuid_v4_simple_is_cryptographically_random_v4() { + let id = uuid_v4_simple(); + let parsed = uuid::Uuid::parse_str(&id).expect("action_id 应是合法 UUID"); + assert_eq!( + parsed.get_version(), + Some(uuid::Version::Random), + "action_id 应是 v4 随机 UUID,实际 {id}" + ); + // 连续取一批,两两不同(CSPRNG 碰撞概率忽略不计) + let mut set = std::collections::HashSet::new(); + for _ in 0..1000 { + assert!(set.insert(uuid_v4_simple()), "1000 次 UUID 不应碰撞"); + } + // 不应再出现旧的 `016x-016x` 单段连字符格式 + assert!( + id.matches('-').count() == 4, + "UUID v4 标准格式应有 4 个连字符,实际 {id}" + ); + } + #[test] fn handle_response_returns_none_for_unknown_action_id() { let pending: PendingMap = Arc::new(Mutex::new(HashMap::new())); diff --git a/packages/orcha-gateway/src/config.rs b/packages/orcha-gateway/src/config.rs index 08235eb..556b60a 100644 --- a/packages/orcha-gateway/src/config.rs +++ b/packages/orcha-gateway/src/config.rs @@ -78,6 +78,17 @@ pub struct IpcConfig { #[serde(default = "default_ipc_port")] pub port: u16, + /// #26:TCP 后端共享密钥。 + /// + /// - `None`(默认):查 `ORCHA_IPC_TCP_SECRET` 环境变量;仍为空则 TCP 不做认证 + /// (仅 dev/CI 用,Gateway 启动时打警告:任意本机进程可连入伪造触发/审批)。 + /// - `Some(s)`:TCP 连接建立后,客户端必须先发 `AUTH \n`,服务端校验通过才 + /// 进入 JSON-line 协议;不匹配立即断开。 + /// + /// Unix socket 后端靠文件系统权限 0600 保护(见 #17),不需要此字段。 + #[serde(default)] + pub tcp_secret: Option, + /// Smoke 测试端口(纯文本 TCP,一行 = 一个任务)。0 = 禁用。默认 7423。 /// 仅 `smoke` feature 启用时编译。 #[cfg(feature = "smoke")] @@ -85,6 +96,18 @@ pub struct IpcConfig { pub smoke_port: u16, } +impl IpcConfig { + /// 实际生效的 TCP 密钥:config 字段优先,否则查 `ORCHA_IPC_TCP_SECRET` 环境变量。 + /// 两处都未配置返回 `None`(TCP 不做认证,dev 模式)。 + pub fn effective_tcp_secret(&self) -> Option { + self.tcp_secret.clone().or_else(|| { + std::env::var("ORCHA_IPC_TCP_SECRET") + .ok() + .filter(|s| !s.is_empty()) + }) + } +} + fn default_ipc_kind() -> String { "auto".to_string() } @@ -102,6 +125,7 @@ impl Default for IpcConfig { Self { kind: default_ipc_kind(), port: default_ipc_port(), + tcp_secret: None, #[cfg(feature = "smoke")] smoke_port: default_smoke_port(), } diff --git a/packages/orcha-gateway/src/ipc.rs b/packages/orcha-gateway/src/ipc.rs index 17b3f10..0264060 100644 --- a/packages/orcha-gateway/src/ipc.rs +++ b/packages/orcha-gateway/src/ipc.rs @@ -153,6 +153,7 @@ mod tcp_backend { mod unix_backend { use super::{IpcAddr, IpcStream}; use std::io::{self, Read, Write}; + use std::os::unix::fs::PermissionsExt; use std::os::unix::net::{UnixListener, UnixStream}; pub struct UnixTransport { @@ -201,6 +202,13 @@ mod unix_backend { // 清理可能残留的旧 socket 文件 let _ = std::fs::remove_file(path); let listener = UnixListener::bind(path)?; + // #17: 收紧 socket 文件权限为 0600,防止同机其他用户连接。 + // 默认受 umask 影响(常为 0755/0777),多用户系统下可被他人连入 + // 伪造触发/绕过审批。设权限失败则清理并报错,避免遗留可连 socket。 + if let Err(e) = std::fs::set_permissions(path, PermissionsExt::from_mode(0o600)) { + let _ = std::fs::remove_file(path); + return Err(e); + } Ok(Self { listener }) } @@ -242,6 +250,22 @@ impl IpcListener { IpcListener::Tcp(t) => t.accept(), } } + + /// 当前 listener 是否为 TCP 后端。 + pub fn is_tcp(&self) -> bool { + matches!(self, IpcListener::Tcp(_)) + } + + /// #26:接受连接并做共享密钥握手。 + /// + /// - **Unix**:文件系统权限 0600 已保护(#17),无需握手,直接返回 stream。 + /// - **TCP + Some(secret)**:accept 后读首行,必须为 `AUTH `, + /// 不匹配/缺失/超时立即断开(返回 `Err`,调用方丢弃 stream)。 + /// - **TCP + None**:打印警告后放行(dev 向后兼容;生产应配 secret)。 + pub fn accept_authenticated(&self, secret: Option<&str>) -> io::Result> { + let stream = self.accept()?; + server_authenticate(stream, self.is_tcp(), secret) + } } /// 统一的服务端 bind:根据 IpcAddr 类型选后端。 @@ -282,6 +306,131 @@ pub fn connect_stream(addr: &IpcAddr) -> io::Result> { } } +/// #26:客户端连上后做共享密钥握手(仅 TCP 发送 `AUTH \n`)。 +/// +/// Unix socket 靠文件系统权限保护,不需要握手。与 +/// [`IpcListener::accept_authenticated`] 对应。 +pub fn connect_stream_authenticated( + addr: &IpcAddr, + secret: Option<&str>, +) -> io::Result> { + let is_tcp = matches!(addr, IpcAddr::Tcp(_, _)); + let mut stream = connect_stream(addr)?; + client_authenticate(&mut *stream, is_tcp, secret)?; + Ok(stream) +} + +// ============================================================ +// #26:TCP 共享密钥握手 +// ============================================================ + +/// 握手前缀:客户端首行必须发 `AUTH \n`。 +const AUTH_LINE_PREFIX: &str = "AUTH "; +/// AUTH 行最大字节数(含前缀 + secret + 换行),防恶意大行耗内存。 +const AUTH_MAX_LINE: usize = 512; + +/// 服务端在 accept 后认证(仅 TCP)。 +/// +/// - Unix:直接返回 stream(文件系统 0600 已保护) +/// - TCP + Some(secret):读首行,必须为 `AUTH `,否则返回 `Err` +/// (调用方丢弃 stream 即断开连接) +/// - TCP + None:打印警告,放行(dev 向后兼容) +fn server_authenticate( + mut stream: Box, + is_tcp: bool, + secret: Option<&str>, +) -> io::Result> { + if !is_tcp { + return Ok(stream); + } + match secret { + None => { + eprintln!( + "[gateway] 警告: IPC TCP 启用但未配 tcp_secret / ORCHA_IPC_TCP_SECRET,\ + 任意本机进程可连入伪造触发/审批(#26)" + ); + Ok(stream) + } + Some(expected) => { + // 读首行(到 \n,限长防滥用) + let line = read_line_with_limit(&mut *stream, AUTH_MAX_LINE)?; + let provided = line + .strip_prefix(AUTH_LINE_PREFIX) + .map(|s| s.trim_end_matches(['\r', '\n'])); + match provided { + Some(p) if constant_time_eq(p.as_bytes(), expected.as_bytes()) => Ok(stream), + _ => Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "IPC TCP 握手失败:AUTH 密钥不匹配或缺失", + )), + } + } + } +} + +/// 客户端连上后发 `AUTH \n`(仅 TCP + Some(secret))。 +fn client_authenticate( + stream: &mut dyn IpcStream, + is_tcp: bool, + secret: Option<&str>, +) -> io::Result<()> { + if !is_tcp { + return Ok(()); + } + if let Some(s) = secret { + let mut line = String::with_capacity(AUTH_LINE_PREFIX.len() + s.len() + 1); + line.push_str(AUTH_LINE_PREFIX); + line.push_str(s); + line.push('\n'); + stream.write_all(line.as_bytes())?; + stream.flush()?; + } + Ok(()) +} + +/// 读一行(到 `\n`),限长防恶意大行耗内存。`\n` 不包含在返回值里。 +fn read_line_with_limit(stream: &mut dyn IpcStream, max: usize) -> io::Result { + let mut buf = Vec::with_capacity(64); + let mut byte = [0u8; 1]; + loop { + match stream.read(&mut byte) { + Ok(0) => { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "IPC 握手:连接在对端发 AUTH 前关闭", + )) + } + Ok(_) => { + if byte[0] == b'\n' { + break; + } + buf.push(byte[0]); + if buf.len() > max { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "IPC 握手:AUTH 行过长", + )); + } + } + Err(e) => return Err(e), + } + } + Ok(String::from_utf8_lossy(&buf).to_string()) +} + +/// 定长比较,避免 timing attack 泄露密钥前缀。 +/// 长度不同直接返回 false(不泄露长度信息以外的内容)。 +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + #[cfg(test)] mod tests { use super::*; @@ -354,4 +503,219 @@ mod tests { handle.join().unwrap(); } + + #[test] + #[cfg(unix)] + fn unix_socket_permissions_are_restricted() { + // #17:bind 后 socket 文件权限应为 0600,防止同机其他用户连接。 + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("perm.sock"); + let addr = IpcAddr::Unix(sock.clone()); + + let _server = bind(&addr).expect("bind"); + + let mode = std::fs::metadata(&sock) + .expect("socket 元数据") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "socket 权限应为 0600,实际 {mode:#o}(#17)"); + } + + // ============================================================ + // #26:TCP 共享密钥握手 + // ============================================================ + + /// 找一个本机空闲端口:bind 一次拿到端口,立刻 drop,复用端口。 + fn free_tcp_port() -> u16 { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap().port() + } + + /// #26:正确密钥握手成功,之后可正常收发 JSON-line 消息。 + #[test] + fn tcp_handshake_succeeds_with_correct_secret() { + let port = free_tcp_port(); + let addr = IpcAddr::Tcp("127.0.0.1".to_string(), port); + let secret = "s3cret-token-#26".to_string(); + + let server = bind(&addr).unwrap(); + let secret_clone = secret.clone(); + let server_handle = std::thread::spawn(move || { + let mut stream = server.accept_authenticated(Some(&secret_clone)).unwrap(); + // 握手通过后,正常读 JSON-line 消息 + let mut buf = [0u8; 5]; + use std::io::Read; + let _ = stream.read(&mut buf); + buf + }); + + let mut client = connect_stream_authenticated(&addr, Some(&secret)).unwrap(); + client.write_all(b"hello").unwrap(); + client.flush().unwrap(); + drop(client); + + let buf = server_handle.join().unwrap(); + assert_eq!(&buf, b"hello", "握手通过后应能正常收发"); + } + + /// #26:密钥不匹配时,服务端 accept_authenticated 返回 PermissionDenied, + /// 且后续消息不会被处理(攻击者无法注入触发/审批)。 + #[test] + fn tcp_handshake_rejects_wrong_secret() { + let port = free_tcp_port(); + let addr = IpcAddr::Tcp("127.0.0.1".to_string(), port); + let server_secret = "correct-secret".to_string(); + let client_wrong_secret = "wrong-secret".to_string(); + + let server = bind(&addr).unwrap(); + let server_handle = std::thread::spawn(move || { + // 服务端期望正确密钥 + server.accept_authenticated(Some(&server_secret)) + }); + + // 客户端发错误密钥 + let mut client = connect_stream_authenticated(&addr, Some(&client_wrong_secret)).unwrap(); + // 客户端发完 AUTH 行后立刻注入一条恶意消息(不应被服务端处理) + client + .write_all(b"{\"type\":\"trigger\",\"malicious\":true}\n") + .unwrap(); + client.flush().unwrap(); + drop(client); + + let result = server_handle.join().unwrap(); + let err = match result { + Ok(_) => panic!("密钥不匹配应被拒绝,实际握手成功"), + Err(e) => e, + }; + assert_eq!( + err.kind(), + std::io::ErrorKind::PermissionDenied, + "应返回 PermissionDenied,实际 {err:?}" + ); + } + + /// #26:客户端不发 AUTH 行(直接发消息),服务端应拒绝。 + /// 模拟未授权进程直接连 TCP 注入伪造触发。 + #[test] + fn tcp_handshake_rejects_missing_auth_line() { + let port = free_tcp_port(); + let addr = IpcAddr::Tcp("127.0.0.1".to_string(), port); + let secret = "guard-secret".to_string(); + + let server = bind(&addr).unwrap(); + let server_handle = std::thread::spawn(move || server.accept_authenticated(Some(&secret))); + + // 攻击者:直接连,不发 AUTH,直接注入伪造审批响应 + let mut attacker = connect_stream(&addr).unwrap(); + attacker + .write_all(b"{\"type\":\"approval_response\",\"action_id\":\"forged\"}\n") + .unwrap(); + attacker.flush().unwrap(); + drop(attacker); + + let result = server_handle.join().unwrap(); + assert!(result.is_err(), "无 AUTH 行应被拒绝"); + } + + /// #26:未配 secret(None)时 TCP 仍可连(dev 向后兼容)。 + #[test] + fn tcp_handshake_allows_when_no_secret_configured() { + let port = free_tcp_port(); + let addr = IpcAddr::Tcp("127.0.0.1".to_string(), port); + + let server = bind(&addr).unwrap(); + let server_handle = std::thread::spawn(move || { + // None = dev 模式不认证 + let mut stream = server.accept_authenticated(None).unwrap(); + let mut buf = [0u8; 5]; + use std::io::Read; + let _ = stream.read(&mut buf); + buf + }); + + // 客户端也不发 AUTH(secret=None) + let mut client = connect_stream_authenticated(&addr, None).unwrap(); + client.write_all(b"hello").unwrap(); + client.flush().unwrap(); + drop(client); + + let buf = server_handle.join().unwrap(); + assert_eq!(&buf, b"hello", "None 模式应放行(dev 向后兼容)"); + } + + /// #26:Unix socket 不做握手(文件系统 0600 已保护),secret 被忽略。 + #[test] + #[cfg(unix)] + fn unix_handshake_is_noop_regardless_of_secret() { + let dir = tempfile::tempdir().unwrap(); + let addr = IpcAddr::Unix(dir.path().join("auth.sock")); + let secret = "unused-on-unix".to_string(); + + let server = bind(&addr).unwrap(); + let secret_clone = secret.clone(); + let server_handle = std::thread::spawn(move || { + // Unix 即使传了 secret 也应跳过握手 + let mut stream = server.accept_authenticated(Some(&secret_clone)).unwrap(); + let mut buf = [0u8; 5]; + use std::io::Read; + let _ = stream.read(&mut buf); + buf + }); + + // 客户端:Unix 不发 AUTH 行,直接发数据 + let mut client = connect_stream_authenticated(&addr, Some(&secret)).unwrap(); + client.write_all(b"hello").unwrap(); + client.flush().unwrap(); + drop(client); + + let buf = server_handle.join().unwrap(); + assert_eq!(&buf, b"hello", "Unix 应跳过握手(0600 保护)"); + } + + /// #26:constant_time_eq 不泄露密钥前缀(长度不同直接 false)。 + #[test] + fn constant_time_eq_correctness() { + assert!(constant_time_eq(b"abc", b"abc")); + assert!(!constant_time_eq(b"abc", b"abd")); + assert!(!constant_time_eq(b"abc", b"ab")); // 长度不同 + assert!(!constant_time_eq(b"", b"a")); + assert!(constant_time_eq(b"", b"")); + } + + /// #26:read_line_with_limit 在超长行时报错(防恶意大行耗内存)。 + #[test] + fn read_line_with_limit_rejects_oversized_line() { + // 用内存中的 Cursor 模拟 stream + struct MemStream(std::io::Cursor>); + impl std::io::Read for MemStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.0.read(buf) + } + } + impl std::io::Write for MemStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.get_mut().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + impl IpcStream for MemStream { + fn set_read_timeout(&self, _: Option) -> std::io::Result<()> { + Ok(()) + } + fn try_clone(&self) -> std::io::Result> { + Ok(Box::new(MemStream(std::io::Cursor::new( + self.0.get_ref().clone(), + )))) + } + } + let big: Vec = vec![b'A'; 600]; // 超过 AUTH_MAX_LINE(512) + let mut stream = MemStream(std::io::Cursor::new(big)); + let result = read_line_with_limit(&mut stream, AUTH_MAX_LINE); + assert!(result.is_err(), "超长 AUTH 行应被拒绝"); + } } diff --git a/packages/orcha-gateway/src/lib.rs b/packages/orcha-gateway/src/lib.rs index de3874b..5cec3cc 100644 --- a/packages/orcha-gateway/src/lib.rs +++ b/packages/orcha-gateway/src/lib.rs @@ -135,6 +135,15 @@ pub fn run(config: GatewayConfig) -> Result<()> { // 3. IPC server let ipc_addr = IpcAddr::from_kind(&config.ipc.kind, &config.home, config.ipc.port); let listener = ipc::bind(&ipc_addr).context("bind IPC listener")?; + // #26:TCP 后端共享密钥握手。None(且环境变量也没配)= dev 模式不认证, + // accept_authenticated 内部会打警告。Unix socket 靠 0600 权限保护(#17)。 + let ipc_tcp_secret = config.ipc.effective_tcp_secret(); + if listener.is_tcp() { + match &ipc_tcp_secret { + Some(_) => eprintln!("[gateway] IPC TCP 已启用共享密钥握手(#26)"), + None => eprintln!("[gateway] 警告: IPC TCP 未配 tcp_secret,任意本机进程可连入(#26)"), + } + } eprintln!("[gateway] IPC listening on {}", format_ipc_addr(&ipc_addr)); let submitter = queue.submitter(); @@ -147,11 +156,13 @@ pub fn run(config: GatewayConfig) -> Result<()> { let accept_approval = approval_config.clone(); let accept_wl = runtime_whitelist.clone(); let accept_wl_path = runtime_wl_path.clone(); + let accept_ipc_secret = ipc_tcp_secret.clone(); thread::Builder::new() .name("orcha-accept".into()) .spawn(move || { accept_loop( listener, + accept_ipc_secret, accept_registry, submitter, accept_auth, @@ -292,6 +303,7 @@ fn build_task_store_arc(config: &GatewayConfig) -> Result> { #[allow(clippy::too_many_arguments)] fn accept_loop( listener: IpcListener, + ipc_secret: Option, registry: AdapterRegistry, submitter: TaskSubmitter, auth: Authenticator, @@ -302,7 +314,8 @@ fn accept_loop( cancel_map: crate::queue::TaskCancelMap, ) { loop { - match listener.accept() { + // #26:TCP 后端做共享密钥握手;Unix socket 直接放行(0600 保护)。 + match listener.accept_authenticated(ipc_secret.as_deref()) { Ok(stream) => { let registry = registry.clone(); let submitter = submitter.clone(); @@ -332,8 +345,13 @@ fn accept_loop( } } Err(e) => { - eprintln!("[gateway] accept 失败: {e}"); - thread::sleep(Duration::from_millis(100)); + // 握手失败(密钥不匹配)也走这里,日志区分一下 + if e.kind() == std::io::ErrorKind::PermissionDenied { + eprintln!("[gateway] IPC 连接握手被拒: {e}"); + } else { + eprintln!("[gateway] accept 失败: {e}"); + thread::sleep(Duration::from_millis(100)); + } } } } diff --git a/packages/orcha-gateway/src/queue.rs b/packages/orcha-gateway/src/queue.rs index d6db974..4fa38f1 100644 --- a/packages/orcha-gateway/src/queue.rs +++ b/packages/orcha-gateway/src/queue.rs @@ -766,7 +766,7 @@ fn generate_commit_info( fn fallback_commit_info(task_description: &str) -> (String, String) { let slug = sanitize_branch_slug(&task_description.chars().take(40).collect::()); ( - format!("chore: apply orcha task ({})", &slug), + format!("chore: apply orcha task ({})", slug), if slug.is_empty() { "orcha-task".into() } else { diff --git a/packages/orcha-gateway/src/runtime_whitelist.rs b/packages/orcha-gateway/src/runtime_whitelist.rs index 28707c7..da0f190 100644 --- a/packages/orcha-gateway/src/runtime_whitelist.rs +++ b/packages/orcha-gateway/src/runtime_whitelist.rs @@ -14,8 +14,11 @@ //! //! # 匹配规则 //! -//! - WriteFile / DeleteFile:按 path 的 basename(文件名)精确匹配, -//! 这样不同 worktree 路径前缀不影响匹配。 +//! - WriteFile / DeleteFile:按**完整规范化相对路径**精确匹配(#20), +//! 不再仅取 basename——否则批准 `src/main.rs` 后 LLM 可写任意目录下的 +//! `main.rs`(如 `tests/main.rs`、`.github/main.rs`),构成越权。 +//! 路径会规范化(消去 `.`/`..`、统一分隔符),`src/./main.rs` 与 +//! `src/main.rs` 视作同一文件。 //! - RunCommand:按 `program:arg1 arg2 ...` 精确匹配。 use std::path::Path; @@ -117,14 +120,28 @@ impl RuntimeWhitelist { } } -/// 计算路径指纹:取 basename(文件名),忽略目录前缀。 -/// 这样不同 worktree 路径前缀不影响匹配。 +/// 计算路径指纹:返回**完整规范化相对路径**(#20)。 +/// +/// 仅取 basename 会导致同名文件跨目录越权(批准 `src/main.rs` 后可写 +/// `tests/main.rs`)。此处规范化整个相对路径,使不同目录的同名文件指纹不同。 fn fingerprint_path(path: &str) -> String { - Path::new(path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(path) - .to_string() + normalize_relative(path) +} + +/// 把相对 workspace 的路径规范化:统一分隔符为 '/',消去 '.',解析 '..' +/// (不允许越过根目录,越界的 '..' 被丢弃)。 +fn normalize_relative(path: &str) -> String { + let mut stack: Vec<&str> = Vec::new(); + for comp in path.split(['/', '\\']) { + match comp { + "" | "." => continue, + ".." => { + stack.pop(); + } + other => stack.push(other), + } + } + stack.join("/") } /// 计算命令指纹:`program:arg1 arg2 ...` @@ -141,10 +158,14 @@ mod tests { use super::*; #[test] - fn fingerprint_path_takes_basename() { - assert_eq!(fingerprint_path("src/utils/mod.rs"), "mod.rs"); - assert_eq!(fingerprint_path("/tmp/worktree-x/src/lib.rs"), "lib.rs"); + fn fingerprint_path_normalizes_full_relative_path() { + // 完整相对路径,不再是 basename(#20) + assert_eq!(fingerprint_path("src/utils/mod.rs"), "src/utils/mod.rs"); assert_eq!(fingerprint_path("a.txt"), "a.txt"); + // 规范化:消去 '.' 与 '..',统一分隔符 + assert_eq!(fingerprint_path("src/./main.rs"), "src/main.rs"); + assert_eq!(fingerprint_path("src/../tests/main.rs"), "tests/main.rs"); + assert_eq!(fingerprint_path("src\\lib.rs"), "src/lib.rs"); } #[test] @@ -157,17 +178,34 @@ mod tests { } #[test] - fn matches_write_file_by_basename() { + fn matches_write_file_by_full_relative_path() { let mut wl = RuntimeWhitelist::default(); - wl.write.push("mod.rs".into()); + wl.write.push("src/main.rs".into()); - // basename 匹配,路径前缀不同也算命中 + // 完整相对路径匹配 let action = orcha_core::ApprovalAction::WriteFile { - path: "/tmp/worktree-abc/src/utils/mod.rs".into(), + path: "src/main.rs".into(), content_preview: "".into(), }; assert!(wl.matches(&action)); + // 规范化等价路径也命中 + let action_norm = orcha_core::ApprovalAction::WriteFile { + path: "src/./main.rs".into(), + content_preview: "".into(), + }; + assert!(wl.matches(&action_norm)); + + // 不同目录的同名文件不得命中(#20 的核心:防越权) + let action_evil = orcha_core::ApprovalAction::WriteFile { + path: "tests/main.rs".into(), + content_preview: "".into(), + }; + assert!( + !wl.matches(&action_evil), + "tests/main.rs 不得命中 src/main.rs" + ); + // 不同文件名不命中 let action2 = orcha_core::ApprovalAction::WriteFile { path: "src/lib.rs".into(), @@ -176,6 +214,20 @@ mod tests { assert!(!wl.matches(&action2)); } + #[test] + fn whitelist_does_not_over_match_same_basename_different_dir() { + // #20 回归测试:批准 src/main.rs 后,任意其它目录的 main.rs 都不得自动放行 + let mut wl = RuntimeWhitelist::default(); + wl.write.push("src/main.rs".into()); + for evil in ["tests/main.rs", "build/main.rs", ".github/main.rs"] { + let action = orcha_core::ApprovalAction::WriteFile { + path: evil.into(), + content_preview: "".into(), + }; + assert!(!wl.matches(&action), "{evil} 不应命中 src/main.rs 的白名单"); + } + } + #[test] fn matches_run_command_by_fingerprint() { let mut wl = RuntimeWhitelist::default(); @@ -195,14 +247,24 @@ mod tests { } #[test] - fn matches_delete_file_by_basename() { + fn matches_delete_file_by_full_relative_path() { let mut wl = RuntimeWhitelist::default(); - wl.delete.push("debug.log".into()); + wl.delete.push("logs/debug.log".into()); + // 完整相对路径匹配 let action = orcha_core::ApprovalAction::DeleteFile { - path: "/some/path/debug.log".into(), + path: "logs/debug.log".into(), }; assert!(wl.matches(&action)); + + // 不同目录的同名文件不得命中 + let action_evil = orcha_core::ApprovalAction::DeleteFile { + path: "build/debug.log".into(), + }; + assert!( + !wl.matches(&action_evil), + "build/debug.log 不得命中 logs/debug.log" + ); } #[test] diff --git a/packages/orcha-shell/Cargo.toml b/packages/orcha-shell/Cargo.toml index 97a4527..3622cba 100644 --- a/packages/orcha-shell/Cargo.toml +++ b/packages/orcha-shell/Cargo.toml @@ -17,6 +17,8 @@ thiserror.workspace = true anyhow.workspace = true tiny_http.workspace = true chrono.workspace = true +# #27:API 错误响应用 UUID v4 作 correlation_id,便于排障而不泄露内部细节。 +uuid.workspace = true [dev-dependencies] tempfile = "3" diff --git a/packages/orcha-shell/src/server.rs b/packages/orcha-shell/src/server.rs index 1663821..0b78bd3 100644 --- a/packages/orcha-shell/src/server.rs +++ b/packages/orcha-shell/src/server.rs @@ -34,6 +34,9 @@ use tiny_http::{Header, Method, Response, Server, StatusCode}; pub struct HttpServer { home: PathBuf, port: u16, + /// 可选访问令牌(#16)。设置后所有请求需携带 Bearer/Cookie/`?token=` 凭证, + /// 否则返回 401。未设置(None)时保持旧行为(无认证),向后兼容。 + auth_token: Option, } impl HttpServer { @@ -41,23 +44,37 @@ impl HttpServer { Self { home: home.into(), port, + auth_token: None, } } + /// 启用访问令牌认证(#16)。传 None 等价于不认证。 + pub fn with_auth_token(mut self, token: Option) -> Self { + self.auth_token = token; + self + } + /// 阻塞运行服务器。Ctrl-C 时退出。 pub fn serve(&self) -> Result<()> { let addr: SocketAddr = ([127, 0, 0, 1], self.port).into(); let server = Server::http(addr).map_err(|e| anyhow::anyhow!("bind {addr} 失败: {e}"))?; - eprintln!( - "orcha shell serve: http://{addr} (home: {})", - self.home.display() - ); + match &self.auth_token { + Some(t) => eprintln!( + "orcha shell serve: http://{addr}/?token={t} (home: {}, 认证已启用)", + self.home.display() + ), + None => eprintln!( + "orcha shell serve: http://{addr} (home: {}, 警告: 未启用认证)", + self.home.display() + ), + } for request in server.incoming_requests() { // 每个请求一个线程;demo 规模足够。 let home = self.home.clone(); + let auth_token = self.auth_token.clone(); std::thread::spawn(move || { - if let Err(e) = handle(request, &home) { + if let Err(e) = handle(request, &home, &auth_token) { eprintln!("handler error: {e}"); } }); @@ -66,17 +83,134 @@ impl HttpServer { } } -fn handle(request: tiny_http::Request, home: &Path) -> Result<()> { +fn handle(request: tiny_http::Request, home: &Path, auth_token: &Option) -> Result<()> { let url = request.url().to_string(); let method = request.method().clone(); - let path = url.split('?').next().unwrap_or(&url); - let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let (path, query) = url.split_once('?').unwrap_or((&url, "")); + + // #16: 可选令牌认证(Bearer / Cookie / ?token=) + if let Some(expected) = auth_token { + let query_token = parse_query_value(query, "token"); + let bearer = header_value(&request, "authorization").and_then(|h| bearer_token(&h)); + let cookie = header_value(&request, "cookie").and_then(|h| cookie_value(&h, "orcha_token")); + if !auth_ok( + expected, + query_token.as_deref(), + bearer.as_deref(), + cookie.as_deref(), + ) { + let resp = Response::from_string(r#"{"error":"unauthorized"}"#) + .with_status_code(StatusCode(401)) + .with_header( + Header::from_bytes(&b"Content-Type"[..], b"application/json").unwrap(), + ); + request.respond(resp).context("respond")?; + return Ok(()); + } + // ?token=xxx 一次性引导:给浏览器种 Cookie,后续 AJAX 自动携带。 + if let Some(t) = query_token.as_deref() { + if t == expected.as_str() { + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let mut resp = route(&segments, &method, home); + let cookie = format!("orcha_token={expected}; HttpOnly; SameSite=Strict; Path=/",); + resp = resp.with_header( + Header::from_bytes(&b"Set-Cookie"[..], cookie.as_bytes()).unwrap(), + ); + request.respond(resp).context("respond")?; + return Ok(()); + } + } + } + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); let resp = route(&segments, &method, home); request.respond(resp).context("respond")?; Ok(()) } +// ============================================================ +// 认证辅助(#16) +// ============================================================ + +/// 三种凭证任一匹配即放行。 +fn auth_ok( + expected: &str, + query_token: Option<&str>, + bearer: Option<&str>, + cookie: Option<&str>, +) -> bool { + query_token == Some(expected) || bearer == Some(expected) || cookie == Some(expected) +} + +/// 从 `Authorization: Bearer ` 提取 token。 +fn bearer_token(header: &str) -> Option { + let v = header.trim(); + let rest = v.strip_prefix("Bearer ")?; + Some(rest.trim().to_string()) +} + +/// 从 Cookie 头提取指定 name 的值。 +fn cookie_value(cookie_header: &str, name: &str) -> Option { + let prefix = format!("{name}="); + for part in cookie_header.split(';') { + let part = part.trim(); + if let Some(rest) = part.strip_prefix(prefix.as_str()) { + return Some(percent_decode(rest)); + } + } + None +} + +/// 从 query string 提取指定 key 的值(百分号解码)。 +fn parse_query_value(query: &str, key: &str) -> Option { + for pair in query.split('&') { + if pair.is_empty() { + continue; + } + let (k, v) = pair.split_once('=')?; + if k == key { + return Some(percent_decode(v)); + } + } + None +} + +/// 极简百分号解码(token 仅含 URL 安全字符时也能正确处理 %XX 与 +)。 +fn percent_decode(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'+' { + out.push(' '); + i += 1; + } else if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(b) = + u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16) + { + out.push(b as char); + i += 3; + continue; + } + out.push('%'); + i += 1; + } else { + out.push(bytes[i] as char); + i += 1; + } + } + out +} + +/// 从请求头取首个匹配 name(大小写不敏感)的值。 +fn header_value(request: &tiny_http::Request, name: &str) -> Option { + request + .headers() + .iter() + .find(|h| h.field.as_str().as_str().eq_ignore_ascii_case(name)) + .map(|h| h.value.to_string()) +} + fn route(segments: &[&str], method: &Method, home: &Path) -> Response>> { // 静态资源 if segments == ["static", "app.css"] { @@ -246,7 +380,6 @@ fn status_str(s: TaskStatus) -> String { fn asset(content_type: &str, body: &str) -> Response>> { Response::from_string(body) .with_header(Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()).unwrap()) - .with_header(cors()) } fn page(_name: &str, body: &str) -> Response>> { @@ -257,17 +390,30 @@ fn page(_name: &str, body: &str) -> Response>> { fn api_json(value: Result) -> Response>> { match value { Ok(v) => Response::from_string(serde_json::to_string(&v).unwrap_or_default()) - .with_header(Header::from_bytes(&b"Content-Type"[..], b"application/json").unwrap()) - .with_header(cors()), - Err(e) => Response::from_string(format!(r#"{{"error":"{e}"}}"#)) - .with_status_code(500) - .with_header(Header::from_bytes(&b"Content-Type"[..], b"application/json").unwrap()) - .with_header(cors()), + .with_header(Header::from_bytes(&b"Content-Type"[..], b"application/json").unwrap()), + Err(e) => { + // #27:对外只返回 generic error + correlation_id,不把 {e} 写进 body。 + // 旧实现 format!("{{\"error\":\"{e}\"}}") 会泄露文件系统路径、 + // DB 路径、模块结构、依赖版本等内部细节给 HTTP 客户端。 + let (body, cid) = internal_error_response(); + eprintln!("[shell] API 内部错误 (correlation_id={cid}): {e:#}"); + Response::from_string(body) + .with_status_code(500) + .with_header(Header::from_bytes(&b"Content-Type"[..], b"application/json").unwrap()) + } } } -fn cors() -> Header { - Header::from_bytes(&b"Access-Control-Allow-Origin"[..], b"*").unwrap() +/// #27:构造对外错误响应体(不泄露内部细节)。 +/// +/// 返回 `(body_json, correlation_id)`: +/// - `body_json`:固定为 `{"error":"internal_error","correlation_id":""}`, +/// 不含任何错误详情。详细错误由 [`api_json`] 记服务端日志(stderr)。 +/// - `correlation_id`:UUID v4,客户端报障时凭此 ID 在服务端日志定位根因。 +fn internal_error_response() -> (String, String) { + let cid = uuid::Uuid::new_v4().to_string(); + let body = format!(r#"{{"error":"internal_error","correlation_id":"{cid}"}}"#); + (body, cid) } #[cfg(test)] @@ -330,4 +476,188 @@ mod tests { // 但 empty(404) 不会 panic。这里仅验证不 panic)。 let _ = resp; } + + // ---- #16 认证辅助测试 ---- + + #[test] + fn auth_ok_accepts_any_of_three_credentials() { + let expected = "s3cret-token"; + assert!(auth_ok(expected, Some("s3cret-token"), None, None)); + assert!(auth_ok(expected, None, Some("s3cret-token"), None)); + assert!(auth_ok(expected, None, None, Some("s3cret-token"))); + } + + #[test] + fn auth_ok_rejects_wrong_and_missing_credentials() { + let expected = "s3cret-token"; + assert!(!auth_ok(expected, Some("wrong"), None, None)); + assert!(!auth_ok(expected, None, None, None), "无凭证应拒绝"); + assert!(!auth_ok(expected, Some(""), None, None), "空 token 应拒绝"); + } + + #[test] + fn bearer_token_parses_header() { + assert_eq!( + bearer_token("Bearer s3cret-token"), + Some("s3cret-token".to_string()) + ); + assert_eq!(bearer_token("bearer x"), None, "大小写敏感,小写应失败"); + assert_eq!(bearer_token("Basic xyz"), None); + assert_eq!(bearer_token(""), None); + } + + #[test] + fn cookie_value_extracts_named_cookie() { + let h = "theme=dark; orcha_token=abc; sid=zzz"; + assert_eq!(cookie_value(h, "orcha_token"), Some("abc".to_string())); + assert_eq!(cookie_value(h, "missing"), None); + assert_eq!( + cookie_value("orcha_token=abc", "orcha_token"), + Some("abc".to_string()) + ); + } + + #[test] + fn parse_query_value_decodes_token() { + assert_eq!( + parse_query_value("token=abc&tab=mem", "token"), + Some("abc".to_string()) + ); + assert_eq!(parse_query_value("foo=1", "token"), None); + // 含百分号编码的 token:%20 → 空格,%2B → '+' + assert_eq!( + parse_query_value("token=a%20b", "token"), + Some("a b".to_string()) + ); + assert_eq!( + parse_query_value("token=a%2Bb", "token"), + Some("a+b".to_string()) + ); + } + + #[test] + fn http_server_with_auth_token_stores_token() { + let srv = HttpServer::new("/tmp", 7421).with_auth_token(Some("t1".into())); + assert_eq!(srv.auth_token.as_deref(), Some("t1")); + let srv2 = HttpServer::new("/tmp", 7421); + assert!(srv2.auth_token.is_none(), "默认无认证(向后兼容)"); + } + + // ---- #23 CORS 回归测试:响应不得再带 Access-Control-Allow-Origin ---- + + #[test] + fn api_responses_do_not_carry_cors_star_header() { + use std::io::{Read, Write}; + // 取一个空闲端口供 tiny_http server 使用 + let port = { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap().port() + }; + let addr: std::net::SocketAddr = ([127, 0, 0, 1], port).into(); + + let server = Server::http(addr).expect("CORS 测试绑定失败"); + let home = tempfile::tempdir().unwrap(); + // 初始化 store,让 /api/tasks 返回 200 + FileTaskStore::new(home.path()).init().unwrap(); + let home_path = home.path().to_path_buf(); + + let client = std::thread::spawn(move || -> Vec { + for _ in 0..50 { + if let Ok(mut s) = std::net::TcpStream::connect(addr) { + s.write_all(b"GET /api/tasks HTTP/1.0\r\nHost: localhost\r\n\r\n") + .unwrap(); + let mut buf = Vec::new(); + s.read_to_end(&mut buf).unwrap(); + return buf; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("无法连接测试服务器"); + }); + + let req = server.incoming_requests().next().expect("no request"); + handle(req, &home_path, &None).unwrap(); + drop(server); + + let resp_bytes = client.join().unwrap(); + let resp = String::from_utf8_lossy(&resp_bytes); + assert!( + !resp + .to_ascii_lowercase() + .contains("access-control-allow-origin"), + "响应不应含 Access-Control-Allow-Origin(#23 已移除 CORS *):\n{resp}" + ); + } + + // ---- #27 错误响应不得泄露内部细节 ---- + + #[test] + fn internal_error_response_does_not_leak_details() { + // 模拟一个含敏感内部细节的错误(文件系统路径 + DB 路径 + 模块结构) + let sensitive = "/home/user/.orcha/orcha.db (rusqlite::inner Os code 13)"; + let err = anyhow::anyhow!("读取任务存储失败: {sensitive}"); + // 证明错误本身确实携带敏感信息——旧实现 format!("{{\"error\":\"{e}\"}}") + // 会把它原样写进 HTTP 响应体泄露给客户端 + assert!( + format!("{err}").contains(sensitive), + "前置:错误 Display 应含敏感信息" + ); + + // 对外响应体不得包含该敏感信息 + let (body, cid) = internal_error_response(); + assert!( + body.contains("internal_error"), + "body 应含 generic error 标识: {body}" + ); + assert!( + body.contains(&cid), + "body 应含 correlation_id 供排障: {body}" + ); + assert!( + !body.contains(sensitive), + "#27: body 不得泄露内部细节(路径/模块/DB),实际: {body}" + ); + + // correlation_id 是合法 UUID v4 + let parsed = uuid::Uuid::parse_str(&cid).expect("correlation_id 应是合法 UUID"); + assert_eq!( + parsed.get_version(), + Some(uuid::Version::Random), + "correlation_id 应是 v4 随机 UUID" + ); + + // 两次调用产生不同 correlation_id + let (_, cid2) = internal_error_response(); + assert_ne!(cid, cid2, "correlation_id 应每次不同"); + } + + #[test] + fn api_json_error_branch_returns_generic_500_with_correlation_id() { + // 端到端验证:传入含敏感信息的错误,api_json 产出的响应体应只有 generic + // error + correlation_id,且不含敏感信息。 + let sensitive = "INTERNAL: /var/lib/orcha/secret path + module::inner"; + let value: Result = Err(anyhow::anyhow!("boom: {sensitive}")); + let resp = api_json(value); + + // tiny_http Response 可通过 as_reader 读 body;这里用 unwrap 的长度推断 + // 改用 Response 的 into_reader 拿到 body 字节 + use std::io::Read; + let mut cursor = resp.into_reader(); + let mut buf = Vec::new(); + cursor.read_to_end(&mut buf).unwrap(); + let body = String::from_utf8(buf).unwrap(); + + assert!( + body.contains("internal_error"), + "响应体应含 generic error: {body}" + ); + assert!( + body.contains("correlation_id"), + "响应体应含 correlation_id: {body}" + ); + assert!( + !body.contains(sensitive), + "#27: 响应体不得泄露内部细节: {body}" + ); + } }