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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ trae-tokens-local/
deploy_commit.sh
commit_restore.sh
rebuild_commit.sh

# Python 字节码(不应入库)
__pycache__/
*.pyc
282 changes: 282 additions & 0 deletions AUDIT-2026-09-15.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
# 独立审计报告(2026-09-15)

> 本报告由独立代码审计产出,**不依赖** `TECHNICAL-DOC.md` 的既有结论。
> 审计范围:`src/` 全部 Go 生产代码(8320 行)+ `login_ui.py`(89KB)。
> 每条发现均经**代码定位**或**可复现实验**验证;未验证的推测已明确标注。

---

## 结论摘要

| 严重度 | 数量 | 说明 |
|---|---|---|
| 🔴 高 | 3 | 需尽快处理 |
| 🟡 中 | 4 | 建议排期 |
| 🟢 低 | 3 | 维护性 |

**与 `TECHNICAL-DOC.md` 的差异**:该文档 12 条发现中,**1 条经实测为误判**
(见 §4),另有 **3 条本报告新增**(其未覆盖)。详见 §4 交叉比对。

---

## 🔴 高风险

### H1. 跨进程并发写凭证文件,可能永久丢失 refreshToken

**位置**:`src/internal/auth/auth.go:204`

```go
tmp := a.FilePath + ".tmp" // 固定文件名
os.WriteFile(tmp, raw, 0o600)
os.Rename(tmp, a.FilePath)
```

**问题**:`Auth.mu` 只保护**同进程内**。而实际架构是:

| 进程 | 触发路径 | 位置 |
|---|---|---|
| `serverd` | 请求前预刷新 | `handler.go:472` |
| `serverd` | 定时保活 | `scheduler.go:374/414` |
| **`ctl`(独立进程)** | 面板点「刷新令牌」 | `ctl/main.go:317` |

两个进程各自持有独立的 `Auth` 实例与独立的 `mu`,**进程间无互斥**;
且 tmp 文件名固定 → 两进程会写同一个临时文件。

**实测复现**(并发 400 次写入):

```
错误数: 3
错误类型: rename .../auth.json.tmp .../auth.json: no such file or directory
```

**最坏后果**(确定性复现):上游 refresh **会轮换 refreshToken**,旧的即失效。

```
落盘的 refreshToken: RT-1
→ 落盘的是较早的 RT-1;真正的 RT-2 已丢失
→ RT-1 可能已被上游轮换失效 → 下次刷新失败 → 需重新登录
```

**注意**:文件**不会损坏**(`rename` 是原子的),丢的是**逻辑上的新值**。

**建议**:
- tmp 名加随机后缀(`os.CreateTemp` 同目录),消除写交错;
- 或加文件锁 `syscall.Flock`(跨进程互斥);
- 或统一刷新权威:`ctl` 只读,刷新由 `serverd` 独占。

---

### H2. 面板默认无登录,管理接口对**全网段**开放

**位置**:`login_ui.py:600-615`、`config.yaml:32-33`

**链路**:

1. `config.yaml` 默认 `webui_user: ""` / `webui_pass: ""`
2. `load_options()` 中 `""` **不是 `None`**,因此**覆盖**掉
`DEFAULT_OPTIONS` 里的 `admin/admin`:

```python
for k in DEFAULT_OPTIONS:
if k in data and data[k] is not None: # "" 通过此判断
opts[k] = data[k]
```

3. 结果 `_webui_enabled() == False`
4. `_mgmt_authorized()` 回退到 `_is_local()`
5. `_is_local()` 对 `172.30.*` / `192.168.*` / `10.*` **一律放行**
6. 而 `config.yaml` 有 `ports: 7870/tcp: 7870` → **端口映射到宿主机**

**后果**:局域网内任意设备访问 `http://<HA>:7870/api/*` 即可**无认证**
调用管理接口(含账号列表、刷新令牌、改配置)。

**为何 ingress 与 LAN 无法区分**:两者源 IP **都是内网**
(ingress 来自 `172.30.x.x`,LAN 来自 `192.168.x.x`),
`_is_local()` 仅按 IP 段判断,无法分辨。

**建议**:
- `config.yaml` 默认不要给空串,或在面板首次启动时**强制生成随机密码**并打印到日志;
- 或移除 `7870/tcp` 的宿主机端口映射(仅走 ingress)。

**当前缓解**:`_mgmt_authorized` 已正确实现、且 `ports_description` 有说明,
属"默认配置不安全"而非"代码缺陷"。

---

### H3. 流式响应中途的上游错误**未识别**,会透传非 SSE 内容

**位置**:`src/internal/upstream/sse.go:196`(`Stream`)

`Stream` 只做**逐行透传**,不解析内容:

```go
line, err := br.ReadString('\n')
if line != "" {
io.WriteString(w, line) // 原样写出
```

而上游**可能在 HTTP 200 的 body 里报错** —— 这一点由本仓库自己的代码佐证:

```go
// client.go:26
ErrHardCredit = provider.ErrHardCredit // 余额不足(402 或 body 关键词)→ 长冷却
// client.go:47-55 Classify 对 body 关键词做判断
```

`Classify` 在 `handler.go:485` **只在 `status >= 400` 时调用**。
若上游以 200 + 错误 JSON 响应(单行、无 `data: ` 前缀):

```
客户端收到: {"code":1,"msg":"余额不足"}
而非: data: {...}\n\n
```

→ OpenAI SDK 解析失败或静默忽略;且**该账号不会被冷却**(未走 Classify),
后续请求继续选中它。

**建议**:`Stream` 首行做嗅探 —— 若非 `data: ` 开头,尝试 `Classify` 并按错误处理,
而不是盲目透传。

**未验证部分**:上游是否真的会以 200 返回错误 body,**未实测**(需真实账号)。
但代码中的 `Classify` 已为 body 关键词做了判断,说明作者认为存在此形态。

---

## 🟡 中风险

### M1. 会话 token 可预测,且全局共享单一会话

**位置**:`login_ui.py:123-131`

```python
tok = hashlib.sha256(("%f|%d" % (time.time(), id(SESSION_FILE))).encode()).hexdigest()
```

- 熵来源仅 `time.time()`(可猜)+ `id()`(同进程常量)
- **未使用** `secrets` / `os.urandom`
- 单一 `SESSION_FILE` → **全局一个 token**,换人登录顶掉前一会话

**建议**:`secrets.token_urlsafe(32)`;如需多会话,改为 token→user 的映射。

**实际风险有限**:登录接口有 IP 限流(5 分钟),且需先通过密码。

---

### M2. `Stream` 的错误被丢弃

**位置**:`handler.go:514`

```go
_ = rt.Upstream.Stream(w, rc) // 返回值被忽略
```

流中途断开(客户端关闭、上游中断)时无任何日志,**排障无迹可循**。

---

### M3. 无访问日志 / 请求 ID

`TECHNICAL-DOC` 亦提及。面板的 `log_message` 被禁用
(`login_ui.py`),`serverd` 仅 `log.Printf` 到 stderr。
出问题时无法关联"哪个请求对应哪条日志"。

---

### M4. `login_ui.py` 89KB 单文件、零测试

面板 + 反向代理 + 子进程管理 + 全部 HTML/CSS/JS 内嵌于一个文件,
**无任何测试**。任何改动都是盲改。

**建议**:至少把 HTML/JS 模板外置,Python 部分按职责拆分。

---

## 🟢 低风险 / 维护性

### L1. `go.mod` 声明未使用的 GUI 依赖

`wailsapp/wails/v2`、`energye/systray` 在 addon 编译路径
(`serverd`/`ctl`/`login`/`logintrae`)中**均未使用**,
徒增构建体积与 `go.sum`。疑似桌面版 fork 残留。

### L2. `cmd/login/main.go` 与 `internal/login/login.go` 重复实现

WorkBuddy OAuth 逻辑存在两份。

### L3. 测试覆盖缺口

现有 8 个测试文件覆盖 auth/config/pool/scheduler/server/upstream/traework,
**缺口**:
- 登录流程(PKCE 交换、回调解析)—— 无单测
- 跨进程集成(面板 ↔ `serverd`)—— 无测试
- `login_ui.py` —— 零测试

---

## §4. 与 `TECHNICAL-DOC.md` 的交叉比对

### ❌ 该文档的 1 条**误判**

> **原文(第 3 条,🔴 高)**:"TraeWork 任意 401 → 永久禁用账号 ...
> `handler` 据此 `Pool.Disable`(永久)... **TraeWork 侧 chat 路径未先 refresh**"

**实测更正**:`handler.go:433` 的预刷新是**平台无关**的,
`rt` 对两个平台是同一个 `provider.Upstream` 接口(`provider.go:87` 定义 `RefreshToken`)。

```go
if acct.NeedsRefresh(h.refreshSkewFor(rt.Kind)) { // ← 已按平台取值
rt.Upstream.RefreshToken(acct)
```

**真实问题是"窗口过窄"**(10min vs traework2api 的 24h),**不是"没刷新"**。
已于本日修复(`RefreshSkew` 按平台区分)。

### ✅ 该文档 5 条经本报告**独立确认**

| 文档结论 | 本报告验证 |
|---|---|
| 时区 UTC 导致签到偏移 | ✅ 确认(已修) |
| TraeWork 强依赖非官方 host | ✅ 确认(代码中 `AgentHost` 硬编码) |
| WorkBuddy 流式 120s 截断 | ✅ 确认(已修) |
| 默认无 API 鉴权 | ⚠️ **部分确认**,见下 |
| `ctl` 与 `serverd` 并发刷新 | ✅ 确认,且**本报告复现了数据丢失**(H1) |

### ⚠️ 该文档 1 条需**修正表述**

> **原文(第 5 条,🟡 中)**:"默认 `api_key=""` → `serverd` 不校验。**仅由 HA ingress 提供外层鉴权**"

**不准确之处**:面板层(`login_ui.py:694-703`)**也**做了 `api_key` 校验,
并非"仅靠 ingress"。且 ingress 只是"外层之一"。

**但本报告发现了更严重的问题(H2)**:当 `api_key` 与 `webui` 凭据**都**留空时,
管理接口对内网**完全开放** —— 这是该文档未指出的。

### ➕ 本报告**新增** 3 条(该文档未覆盖)

- **H3** 流式中途错误未识别(透传非 SSE 内容)
- **M1** 会话 token 可预测 + 全局单会话
- **M2** `Stream` 错误被丢弃

---

## §5. 建议处理顺序

| 优先级 | 项 | 理由 |
|---|---|---|
| 1 | **H2** | 默认配置即暴露管理接口,影响所有用户 |
| 2 | **H1** | 已在实验中复现数据丢失,且触发条件是正常使用 |
| 3 | **H3** | 影响错误处理正确性;但需先确认上游真实行为 |
| 4 | M1 / M4 | 安全加固与可维护性 |
| 5 | L1 / L2 | 清理 |

---

## §6. 审计方法说明

- **静态阅读**:`auth` / `pool` / `server` / `upstream` / `traework` / scheduler 全量
- **可复现实验**:H1 的并发写竞态用最小 Go 程序复现(含数据丢失场景)
- **配置链路推演**:H2 的 `""` vs `None` 覆盖行为用 Python 复现
- **未验证项已显式标注**:H3 的上游 200+错误body 形态无真实账号验证

**未覆盖**:`internal/qoder/*`(443+247+209+168 行)仅做扫描,
未逐行审计;`internal/login_trae`(352+198 行)同上。
Binary file removed __pycache__/login_ui.cpython-311.pyc
Binary file not shown.
47 changes: 36 additions & 11 deletions login_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,23 +597,48 @@ def _client_ip(self):
"""返回直连客户端 IP(ingress 由 HA 反代本地转发,来源恒为本机/容器网段)。"""
return self.client_address[0] if self.client_address else ""

def _is_local(self):
"""HA ingress 转发与本机访问均为本地来源;其余视为公网直连。"""
def _is_loopback(self):
"""仅本机回环(ingress 反代与容器内自调用)。"""
ip = self._client_ip()
if ip in ("127.0.0.1", "::1", "localhost", ""):
return True
if ip.startswith("172.30.") or ip.startswith("172.16.") or ip.startswith("192.168.") or ip.startswith("10."):
return ip in ("127.0.0.1", "::1", "localhost", "")

def _is_private(self):
"""容器/内网网段(含 HA 的 172.30.x 与常见 LAN 段)。"""
ip = self._client_ip()
if self._is_loopback():
return True
return False
return (ip.startswith("172.30.") or ip.startswith("172.16.")
or ip.startswith("192.168.") or ip.startswith("10."))

def _is_ingress(self):
"""是否为 HA ingress 转发请求。

ingress 会把请求转发到 127.0.0.1 并在路径上保留
/api/hassio_ingress/<token>/ 前缀(与第 1081 行的同款判断一致)。
"""
path = self.path or ""
return path.startswith("/api/hassio_ingress/") or "/hassio_ingress/" in path

def _mgmt_authorized(self):
"""管理接口鉴权:已登录会话,或(面板未启用登录时)仅限本地来源。
防止 webui 凭据留空时管理接口经公网 7870 裸奔。"""
"""管理接口鉴权(按可信度从高到低)。

1. 有有效登录会话 → 放行;
2. 面板**未启用**登录时:
- ingress 转发(HA 已做认证)→ 放行;
- 本机回环 → 放行;
- **其它内网来源(LAN 直连)→ 拒绝**。

第 2 条的 LAN 分支是安全修复:此前把所有内网段一并放行,
而 config.yaml 默认 webui 凭据为空、且 7870 端口映射到宿主机,
导致同网段任意设备可无认证调用管理接口
(含账号列表、刷新令牌、修改配置)。
"""
if _webui_check_cookie(self._cookie()):
return True
if not _webui_enabled():
return self._is_local()
return False
if _webui_enabled():
return False
# 未启用登录:只信任 ingress 与本机,不再信任整个内网
return self._is_ingress() or self._is_loopback()


def _handle_login_get(self):
Expand Down
Loading
Loading