Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions peri-agent/src/thread/sqlite_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,42 @@ impl SqliteThreadStore {
restrict_to_owner_unix(&db_path);
let store = Self { pool };
store.init_schema().await?;

// messages.content 含完整对话历史(含用户粘贴的 API key/令牌/源码/PII),
// 限制为 owner-only。WAL/SHM 同步处理。
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let owner_only = std::fs::Permissions::from_mode(0o600);
let dir_only = std::fs::Permissions::from_mode(0o700);
let _ = std::fs::set_permissions(&db_path, owner_only.clone());
// SQLite 的 WAL/SHM 后缀是 `threads.db-wal`(连字符),不是 `threads.db.wal`。
// with_extension("db-wal") 把扩展从 db 替换为 db-wal,得到正确路径。
for suffix in ["wal", "shm"] {
let sidecar = db_path.with_extension(format!("db-{suffix}"));
if sidecar.exists() {
let _ = std::fs::set_permissions(&sidecar, owner_only.clone());
}
}
// 修两层目录:~/.cc-code/threads/ 和 ~/.cc-code/ 都要 0o700
// (cc-claws review:原版只修了一层 parent,grandparent 漏修)
// grandparent 启发式:仅当目录名以 `.` 开头(隐藏配置目录)才 chmod,
// 避免测试环境下误把 /tmp 改成 0o700,也避免项目本地路径被改坏。
if let Some(parent) = db_path.parent() {
let _ = std::fs::set_permissions(parent, dir_only.clone());
if let Some(grandparent) = parent.parent() {
let is_hidden = grandparent
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.starts_with('.'))
.unwrap_or(false);
if is_hidden {
let _ = std::fs::set_permissions(grandparent, dir_only.clone());
}
}
}
}

Ok(store)
}

Expand Down
34 changes: 34 additions & 0 deletions peri-agent/src/thread/sqlite_store_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,3 +398,37 @@
dir_mode
);
}

/// 安全:grandparent 为隐藏目录(`.cc-code` 风格)时也要 chmod 0o700。
/// cc-claws review:原版只修一层 parent,grandparent 漏修。
#[cfg(unix)]
#[tokio::test]
async fn test_grandparent_hidden_dir_permissions_restricted() {
use std::os::unix::fs::PermissionsExt;
let root = tempdir().unwrap();
// 模拟生产布局:root/.cc-code/threads/threads.db
let db_path = root.path().join(".cc-code").join("threads").join("threads.db");
let _store = SqliteThreadStore::new(&db_path).await.unwrap();

let gp_path = root.path().join(".cc-code");
let gp_mode = std::fs::metadata(&gp_path)
.expect("grandparent .cc-code should exist")
.permissions()
.mode();
assert_eq!(
gp_mode & 0o777,
0o700,
"grandparent .cc-code 权限应为 0o700,实际 0o{:o}",
gp_mode
);

// WAL/SHM 后缀验证:sidecar 文件名应该是 `threads.db-wal`(连字符),
// 而不是 `threads.db.wal`(点号)。直接验证路径计算逻辑。
let wal_path = root.path().join(".cc-code").join("threads").join("threads.db-wal");
let wal_via_with_extension = db_path.with_extension("db-wal");
assert_eq!(
wal_path, wal_via_with_extension,
"with_extension(\"db-wal\") 应产出 `threads.db-wal`(连字符),实际 {:?}",
wal_via_with_extension
);
}
8 changes: 8 additions & 0 deletions peri-middlewares/src/mcp/callback_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ impl OAuthCallbackServer {
))
}

/// 在 `wait_for_code` 之前注入 rmcp 生成的 csrf state,启用本地回调服务器的
/// state 校验作为 rmcp `state_store` 之外的纵深防御。
pub fn set_state(&mut self, state: String) {
if !state.is_empty() {
self.state_param = state;
}
}

pub async fn wait_for_code(mut self) -> Result<(String, String), CallbackError> {
let result = tokio::time::timeout(
std::time::Duration::from_secs(CALLBACK_TIMEOUT_SECS),
Expand Down
19 changes: 19 additions & 0 deletions peri-middlewares/src/mcp/callback_server_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,22 @@ async fn test_bind_multiple_servers() {
drop(s1);
drop(s2);
}

#[tokio::test]
async fn test_set_state_enables_callback_validation() {
let (mut server, _uri) = OAuthCallbackServer::bind().await.unwrap();
// 默认 state_param 为空,set_state 之前 parse_callback_url 因 expected_state
// 为空会跳过校验。注入真实 state 后,state 不匹配应被拒绝。
server.set_state("expected-csrf".to_string());
assert_eq!(server.state_param, "expected-csrf");

// 空 state 不应被注入(防止误把默认值覆盖成空)
server.set_state(String::new());
assert_eq!(server.state_param, "expected-csrf");

let mismatch = parse_callback_url("/callback?code=c&state=other", &server.state_param);
assert!(mismatch.is_err(), "state 不匹配应被拒绝");

let ok = parse_callback_url("/callback?code=c&state=expected-csrf", &server.state_param);
assert!(ok.is_ok(), "state 匹配应通过");
}
14 changes: 13 additions & 1 deletion peri-middlewares/src/mcp/oauth_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ impl OAuthFlowManager {
}

// 3. 绑定回调服务器
let (callback_server, redirect_uri) = OAuthCallbackServer::bind().await?;
let (mut callback_server, redirect_uri) = OAuthCallbackServer::bind().await?;

// 4. 启动授权(DCR + PKCE + metadata 发现)
let scopes: Vec<&str> = oauth_config
Expand All @@ -139,6 +139,18 @@ impl OAuthFlowManager {
// 5. 获取授权 URL
let authorization_url = state.get_authorization_url().await?;

// 5.1 从授权 URL 提取 rmcp 生成的 csrf state,启用本地回调服务器的
// state 校验作为 rmcp `state_store` 之外的纵深防御。
if let Ok(parsed) = url::Url::parse(&authorization_url) {
if let Some(state_value) = parsed
.query_pairs()
.find(|(k, _)| k == "state")
.map(|(_, v)| v.into_owned())
{
callback_server.set_state(state_value);
}
}

// 6. 创建 oneshot 通道,通知 TUI 等待用户交互
let (callback_tx, callback_rx) = oneshot::channel::<OAuthCallbackResult>();

Expand Down
17 changes: 12 additions & 5 deletions peri-middlewares/src/tools/filesystem/grep_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -958,15 +958,22 @@ async fn test_grep_files_without_matches_with_offset() {
.unwrap();
// cfg(test) 下按 filename 升序:a/b/c/d/e
// slice(1, 3) → b, c
let lines: Vec<&str> = result.lines().collect();
assert!(
result.starts_with("Found 2 files limit: 2, offset: 1"),
"头部应反映 limit 和 offset: {result}"
);
// 用 contains 而非 lines[N] 硬编码索引,兼容 persist hint 附加行
assert!(result.contains("b.txt"), "slice 第 1 项: {result}");
assert!(result.contains("c.txt"), "slice 第 2 项: {result}");
assert!(!result.contains("a.txt"), "a.txt 应被 offset 跳过: {result}");
assert!(!result.contains("z.txt"), "z.txt 有匹配,不应出现在无匹配列表: {result}");
assert!(lines[1].ends_with("b.txt"), "slice 第 1 项: {result}");
assert!(lines[2].ends_with("c.txt"), "slice 第 2 项: {result}");
// 验证 offset 跳过的文件不在结果中(用 ends_with 避免 persist hint 路径误匹配)
assert!(
!lines.iter().skip(1).any(|l| l.ends_with("a.txt")),
"a.txt 应被 offset 跳过: {result}"
);
assert!(
!lines.iter().skip(1).any(|l| l.ends_with("z.txt")),
"z.txt 有匹配,不应出现在无匹配列表: {result}"
);
}

/// head_limit=0(unlimited)+ offset>0 边界组合
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# session/load 和 session/resume 接受任意 sessionId 无所有权校验

**状态**:Open
**优先级**:中
**创建日期**:2026-06-26
**来源**:cc-code 全项目安全审计 2026-06-26(Finding M5,置信度 8/10)

## 问题描述

`session/load` 和 `session/resume` 处理器把客户端提供的 `sessionId` 直接当 SQLite 主键使用(已用 `bind()` 参数化,**非** SQL 注入)。但 `ThreadId` 是 `String` 类型,从未做 UUID 格式校验,也无所有权校验。任意 ACP 客户端给定任意 ID 即可读取对应线程全部消息,或静默插入一条空的 SessionState。

## 当前行为

```rust
// peri-tui/src/acp_server/requests.rs:224-294 (session/load)
// peri-tui/src/acp_server/requests.rs:346-389 (session/resume)
// 客户端提供的 sessionId 直接当作 thread_id 用于 SQLite 查询,
// 参数化绑定 → 不是 SQL 注入
// 但缺少:
// - UUID 格式校验(ThreadId 是 String,可传任意字符串)
// - 线程存在性校验(缺失时静默插入新 SessionState)
// - 所有权校验(任何 ACP client 都可读任意 thread)
```

```rust
// peri-acp/src/session/mod.rs:91-101
// new_session_with_id 接受任意 String 作为 ID,无格式校验
```

## 预期行为

| 操作 | 当前 | 预期 |
|------|------|------|
| `session/load` 传入不存在的 sessionId | 静默插入新 SessionState | 返回错误 `session_not_found` |
| `session/load` 传入他人 sessionId | 直接读取消息 | 返回 `forbidden` 或要求所有者 token |
| `session/load` 传入 `../../etc/passwd` 等非法 ID | 尝试匹配(SQLite TEXT 主键,无文件系统影响) | UUID 格式校验拒绝 |
| 多客户端访问同一 ACP server | 任意 client 可访问任意 thread | 至少 client 隔离 |

## 利用场景

威胁模型基本是本地单用户(用户运行自己的 agent),所以跨线程读取≈直接读 SQLite 文件(见 H3)。但以下场景风险升级:

1. **多客户端场景**:用户启动一个 ACP server,同时连接多个 IDE 插件 / SDK 客户端。某个不可信的 SDK 客户端(例如自动测试工具)可以通过枚举 sessionId 读其他线程的消息。
2. **SubAgent 隔离失败**:后台 agent 或 fork 的 sub-agent 拿到 ACP 句柄时可越权读主 agent 的其他会话。
3. **远程 IDE 场景**:未来如果 ACP server 暴露到网络(vscode remote、SSH),跨用户读写。

## 修复方案

任选其一,按优先级:

1. **UUID 格式校验**(最小修复):
```rust
fn validate_session_id(id: &str) -> Result<(), AcpError> {
Uuid::parse_str(id).map_err(|_| AcpError::invalid_params("sessionId must be UUID"))?;
Ok(())
}
```
在 `session/load`、`session/resume`、`new_session_with_id` 入口校验。

2. **存在性校验**:`session/load` 在 SQLite 中查询 thread 是否存在,不存在则返回 `session_not_found`,不静默插入。

3. **所有权校验**(长期):thread 表加 `owner` 字段,session 启动时绑定 owner,load/resume 时验证 caller 与 owner 匹配。

4. **文档化威胁模型**:若 ACP server 始终单用户本地,在 ACP spec 中明确"sessionId 是可信客户端输入"。

## 涉及文件

- `peri-tui/src/acp_server/requests.rs:224-294` — `session/load` 处理器
- `peri-tui/src/acp_server/requests.rs:346-389` — `session/resume` 处理器
- `peri-acp/src/session/mod.rs:91-101` — `new_session_with_id` 入口

## 状态变更记录

| 日期 | 从 | 到 | 操作人 | 说明 |
|------|-----|-----|--------|------|
| 2026-06-26 | — | Open | agent | 创建(安全审计 M5) |
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# input-history.json 全局可读(0o644)

**状态**:Open
**优先级**:中
**创建日期**:2026-06-26
**来源**:cc-code 全项目安全审计 2026-06-26(Finding M4,置信度 8/10,已磁盘验证)

## 问题描述

`save_input_history()` 通过 `std::fs::write(&tmp_path, json)` 写入用户 TUI 提交的每一条原始输入到 `~/.peri/input-history.json`,但未设置任何文件权限。文件落到默认 umask 0o644,**全局可读**。该文件包含用户在 TUI 中提交的原始提示词文本,可能包含内联 API key、调试命令、粘贴的密钥。

## 当前行为

已磁盘验证(2026-06-26):

```
$ stat -c "%a %n" ~/.peri/input-history.json
644 /home/jackbot/.peri/input-history.json ← 全局可读
```

```rust
// peri-tui/src/app/history_persistence.rs:32-58
// save_input_history() 用 std::fs::write(&tmp_path, json) 写入,
// 没有 set_permissions 调用
```

对比 `~/.peri/oauth_tokens.json` 已正确实现 0o600(`-rw-------`)。

## 预期行为

| 文件 | 当前权限 | 目标权限 |
|------|---------|---------|
| `~/.peri/input-history.json` | 644 | 600 |
| `~/.peri/`(目录) | 755 | 700 |

## 利用场景

1. 共享开发机 / CI runner 上,受害者用 peri TUI。
2. 用户在 prompt 中直接输入或粘贴 API key、生产数据库连接串、密钥等(常见用法,例如"帮我看下这个 token 为什么不对:sk-...")。
3. 历史输入被自动持久化到 `~/.peri/input-history.json`(0o644)。
4. 同机另一账户 `cat ~victim/.peri/input-history.json | grep -E 'sk-|password|token'` 即可批量捞走敏感串。

## 修复方案

在 `save_input_history()` 写入后立即应用权限:

```rust
use std::os::unix::fs::PermissionsExt;

// fs::write(&tmp_path, json) 之后
#[cfg(unix)]
{
let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600));
}
// rename 之前注意:set_permissions 在 rename 之前应用到 tmp_path,
// rename 会保留 tmp_path 的权限到最终路径
```

参考 `peri-middlewares/src/mcp/auth_store.rs:88-97` 的 `ensure_file` 实现。

Windows 不受影响(ACL 默认按用户隔离)。

## 涉及文件

- `peri-tui/src/app/history_persistence.rs:32-58` — `save_input_history()` 写入逻辑
- `peri-tui/src/app/history_persistence.rs:54` — `fs::write` 调用位置(需要补 `set_permissions`)

## 关联

- 同源问题见 [[2026-06-26-security-sqlite-threads-db-world-readable]](H3)
- `~/.peri/oauth_tokens.json` 已实现 0o600,可作为参考模式

## 状态变更记录

| 日期 | 从 | 到 | 操作人 | 说明 |
|------|-----|-----|--------|------|
| 2026-06-26 | — | Open | agent | 创建(安全审计 M4,已磁盘验证 0o644) |
Loading
Loading