Skip to content

Commit f65dab9

Browse files
committed
fix(security): 全量审查修复批次(回调绑定 / SSRF / 脱敏 / 扫描接线)
代码修复: - `/authorize` 回调绑定改判 `loginTraceID`(TRAE 原样回显的 `(machine_id + device_id)[-16:]`),撤掉 Origin/Referer 同源启发式: 合法回调本就跨源、https 下还会丢 Referer,既拦不住真攻击(加 no-referrer 即绕过)又可能误杀真回调。新增 `callback.login_trace_id()` 供生成与校验 共用同一公式;TTL 检查排在绑定检查之前。 - 告警 webhook 投递前按 DNS 解析结果复核(新增 `blocked_webhook_host`, IPv6 zone 剥离,解析失败 fail closed),堵「域名指向内网 / 云元数据」; 自建 client 补 `trust_env=False`。 - 刷新任务落库「需重新登录」改用固定文案 `RELOGIN_REASON`,上游原文只进日志。 - `stats.query` 两处 `assert` 改显式 `raise ValueError`(SQL 片段白名单闸门)。 - `Database.__init__` 把 parent 目录 chmod 0700(失败不阻断启动)。 - MD5 三处补 `usedforsecurity=False`(复刻上游协议,非本服务安全机制)。 工程卫生: - CI 新增 security job(bandit -ll + pip-audit)与前端 `pnpm audit --prod`; `pyproject [tool.bandit]` 关 B608(15 处全白名单拼接,附理由)。 - 新增 `.github/dependabot.yml`(uv / npm / actions / docker)与 SECURITY.md (私密报告入口 + 部署方信任边界须知)。 - 新增 `.env.example`(仅 APP_SECRET 必填,指向 README 配置节)。 - pyproject description 更正为七渠道。 测试:新增/更新覆盖上述分支(回调缺失/错配 trace、DNS 复核各分支、 relogin 固定文案、分组键白名单)。后端 2449 passed / 100%,ruff 干净; 前端 tsc + vitest 385 + build 全绿。 文档:PROPOSAL §3.5/§6、TECHNICAL §14、README(.en) 同步回调绑定与 webhook 投递前复核口径。
1 parent e2d45a1 commit f65dab9

24 files changed

Lines changed: 375 additions & 32 deletions

‎.env.example‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# 环境变量模板:复制为 .env 后按需修改。
2+
#
3+
# cp .env.example .env
4+
#
5+
# 只有 APP_SECRET 必填(其余留空即用括号内的默认值)。完整清单与逐项说明见
6+
# README「配置」一节(权威默认值在 src/config.py,且每一项都已透传到
7+
# docker-compose.yml)。
8+
#
9+
# .env 不入库(见 .gitignore);切勿把真实 APP_SECRET 提交进仓库。
10+
11+
# 凭证列加密密钥,≥16 字符。丢失 = 已存凭证全部不可解,只能重录(无密钥轮换)。
12+
# 生成:python -c "import secrets; print(secrets.token_urlsafe(32))"
13+
APP_SECRET=
14+
15+
# 管理台管理员用户名(逗号分隔)。仅引导期生效:DB 里已有角色后不再覆盖。
16+
ADMIN_USERNAMES=admin
17+
18+
# 浏览器可达地址。远程部署必须设成真实可达地址,TRAE 登录回调依赖它。
19+
PUBLIC_BASE_URL=http://127.0.0.1:8000
20+
21+
# 监听地址与端口(PORT 同时决定 compose 的宿主机映射端口)。
22+
HOST=127.0.0.1
23+
PORT=8000
24+
25+
# SQLite 与运行数据目录。
26+
DATA_DIR=./data
27+
28+
# 仅引导期:老式用户文件路径,启动时一次性导入 SQLite(幂等)。
29+
USERS_FILE=secrets/users.txt
30+
31+
# 请求未指定模型(或为 auto/空)时使用的默认模型。
32+
DEFAULT_MODEL=glm-5.2
33+
34+
# Host 白名单(逗号分隔,防 DNS rebinding);留空 = 放行本地回环与 PUBLIC_BASE_URL 主机。
35+
ALLOWED_HOSTS=
36+
37+
# 是否暴露 /docs 与 /openapi.json;默认关闭(匿名可拉全量端点清单 = 送侦察图)。
38+
ENABLE_DOCS=false
39+
40+
# 是否采信 X-Forwarded-For(仅当本服务前面恰好一层受信反代时开启)。
41+
TRUST_PROXY=false
42+
43+
# 按渠道出站代理,格式 `渠道=代理URL;渠道2=代理URL2`;留空 = 全部直连。
44+
# 渠道取七个渠道名,协议支持 http/https/socks5/socks5h。环境变量 HTTP_PROXY 等一律不采信。
45+
PROVIDER_PROXIES=

‎.github/dependabot.yml‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
version: 2
2+
updates:
3+
# Python 运行时依赖(uv.lock 由 CI 的 uv sync --frozen 校验)
4+
- package-ecosystem: "uv"
5+
directory: "/"
6+
schedule:
7+
interval: "weekly"
8+
open-pull-requests-limit: 5
9+
groups:
10+
python:
11+
patterns: ["*"]
12+
13+
# 前端依赖
14+
- package-ecosystem: "npm"
15+
directory: "/web"
16+
schedule:
17+
interval: "weekly"
18+
open-pull-requests-limit: 5
19+
groups:
20+
web:
21+
patterns: ["*"]
22+
23+
# CI 里用 SHA 固定的 action 需要定期跟进新版本
24+
- package-ecosystem: "github-actions"
25+
directory: "/"
26+
schedule:
27+
interval: "weekly"
28+
open-pull-requests-limit: 5
29+
groups:
30+
actions:
31+
patterns: ["*"]
32+
33+
# 基础镜像(Dockerfile / compose)
34+
- package-ecosystem: "docker"
35+
directory: "/"
36+
schedule:
37+
interval: "weekly"
38+
open-pull-requests-limit: 5

‎.github/workflows/ci.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ jobs:
1717
- name: 测试与覆盖率(门槛 100%)
1818
run: uv run pytest -q --cov=src --cov-report=term --cov-fail-under=100
1919

20+
security:
21+
runs-on: ubuntu-latest
22+
steps:
23+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
24+
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
25+
- run: uv sync --frozen
26+
- name: bandit(中危及以上,配置见 pyproject [tool.bandit])
27+
run: uvx bandit -c pyproject.toml -r src scripts -ll
28+
- name: pip-audit(已知漏洞,含 dev 依赖)
29+
run: uvx pip-audit --skip-editable
30+
2031
frontend:
2132
runs-on: ubuntu-latest
2233
defaults:
@@ -33,6 +44,8 @@ jobs:
3344
cache: pnpm
3445
cache-dependency-path: web/pnpm-lock.yaml
3546
- run: pnpm install --frozen-lockfile
47+
- name: 依赖漏洞(生产依赖,high 及以上阻断)
48+
run: pnpm audit --prod --audit-level=high
3649
- run: pnpm exec tsc --noEmit
3750
- run: pnpm exec vitest run
3851
- run: pnpm build

‎PROPOSAL.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ Provider 承担上游协议私有部分:发请求、解析事件、分类错
115115
`AuthFlow` 三值:`poll`(后端轮询上游:CB / Qoder)、`callback`(浏览器 302 回本服务 `/authorize`:TRAE)、`paste`(用户把回调链接粘回:CodeArts,服务端监听不到本机回环端口)。
116116

117117
- **回调统一走主端口** `/authorize`;远程部署只需暴露一个端口。回调地址要写进登录 URL,故必须可配:`PUBLIC_BASE_URL`。
118-
- 前端在「凭证管理」页内实现:**poll** 拿到 `authUrl` 开新窗、轮询 `/api/auth/upstream/poll`;**callback** 开授权窗、浏览器 302 回 `/authorize` 直接落库,前端轮询凭证列表出现新条目即视为完成(上游不回传 state)。`/authorize` 无鉴权(浏览器 302 不带 key),故按**发起人**绑定 + 待完成登录有 TTL(10 分钟)。
118+
- 前端在「凭证管理」页内实现:**poll** 拿到 `authUrl` 开新窗、轮询 `/api/auth/upstream/poll`;**callback** 开授权窗、浏览器 302 回 `/authorize` 直接落库,前端轮询凭证列表出现新条目即视为完成(上游不回传 state)。`/authorize` 无鉴权(浏览器 302 不带 key),故按**发起人会话** + **`loginTraceID`**(TRAE 原样回显登录 URL 里的 `(machine_id + device_id)[-16:]`,攻击者拿不到)双重绑定,且待完成登录有 TTL(10 分钟)。
119119

120120
### 3.6 中立事件层
121121

@@ -171,10 +171,10 @@ DDL 以 [src/db/schema.sql](src/db/schema.sql) 为准,共 **11 张表**,`SCH
171171
- **凭证加密**:`data_enc` 走 Fernet,密钥走 `APP_SECRET`(≥16 字符,拒绝低熵值);**丢失 = 已存凭证全部不可解,只能重录**,不做密钥轮换。解密失败返回可行动错误码,不暴露裸 500。
172172
- TLS 校验默认开启;Host / Origin 白名单;CSP `frame-ancestors 'none'`、`object-src 'none'`、`base-uri 'self'`、`form-action 'self'`。**请求体上限**:登录 8KB、其余 16MB(ASGI 层按实际字节计数,`chunked` 不能绕过)。登录三级限流(全局 / IP / 用户名)+ PBKDF2 并发上限;失败键有数量上限 + 过期淘汰(防内存 DoS)。
173173
- **API Key 仅存摘要**,明文只在创建时返回一次;可按 Key 限定渠道绑定、来源 IP 白名单(默认**不信** `X-Forwarded-For`,仅 `TRUST_PROXY=true` 时取 XFF 最后一个条目)、模型白名单(fnmatch,无法判定模型名时 fail closed)与到期时间。
174-
- **会话**:Cookie `SameSite=Lax` + 写操作 CSRF 校验(含 logout);**无 Cookie 的写端点(登录 / `/authorize`)也校验 Origin**(防 login CSRF);会话与 API Key 除签名 / 摘要外校验用户仍存在、启用且 epoch 一致。
174+
- **会话**:Cookie `SameSite=Lax` + 写操作 CSRF 校验(含 logout);**无 Cookie 的写端点(登录 / `/authorize`)也做跨站防护**:登录按 Origin/Referer 同源校验(防 login CSRF),`/authorize` 按上游原样回显的 `loginTraceID` 绑定到**发起登录那一次**(TRAE 回调本就跨源,Origin/Referer 既拦不住真攻击也可能误杀真回调);会话与 API Key 除签名 / 摘要外校验用户仍存在、启用且 epoch 一致。
175175
- **错误响应不回流上游正文**:`/v1` 与外层的错误文案只带受控标识(HTTP 状态码 / 上游业务码 / 异常类名),上游错误体摘要只进服务端日志;未匹配的 `/api`、`/v1` 路径返回 JSON 404(不落到 SPA 的 200 + HTML)。**日志脱敏**:不打印 Token、完整请求体;审计写 INFO(含操作人),**绝不记密码/令牌明文**。
176176
- **租户隔离**:`/api/stats/events` 与 `/api/stats/by-credential` 的**凭证昵称**取自全局共享池(常含邮箱/手机),仅 admin/operator 可见;viewer 即便只看自己的记录也不下发该字段。
177-
- **热更配置写入即校验**:float 拒 NaN/±inf;字符串类有长度上限;告警 webhook 只接受 `http(s)` 并做 **SSRF 校验**(拒内网 / 回环 / 链路本地 / 保留 / 多播 / 未指定地址),外发载荷对 `nickname` 打码,多地址并发投递并加总预算。
177+
- **热更配置写入即校验**:float 拒 NaN/±inf;字符串类有长度上限;告警 webhook 只接受 `http(s)` 并做 **SSRF 校验**(写入期拒字面量内网 / 回环 / 链路本地 / 保留 / 多播 / 未指定地址,**投递前再按 DNS 解析结果复核一次**、解析失败即阻断),外发载荷对 `nickname` 打码,多地址并发投递并加总预算。告警出站 client `trust_env=False`,不吃宿主代理环境变量。
178178
- **上游流解析有界**:SSE 单行与单帧缓冲设上限,非 2xx 错误体有界读取;OAuth state store 的 owner/consume 有 TTL 判定。**入站解析把关**:三个出站协议在入口拒绝越界数值字段(`max_tokens` ≤0 或超上限、`temperature`/`top_p` 为 NaN/Inf)与 JSON 里的 `NaN`/`Infinity`。
179179
- **OAuth 登录弹窗**只允许 `http(s)` scheme,弹窗 `opener` 置空。**库版本守卫**:`user_version` 高于本程序时 `apply_schema` 拒绝执行。`scripts/hash_password` / `create_user` 支持 `--password-stdin`;拒绝含 `:` / 换行的用户名。
180180
- **不做**:mTLS;面向管理台与端口的 IP 限制(交给反向代理)。不做全量请求审计(统计表已是脱敏的请求级记录)。

‎README.en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ Key variables below; the full list is in `src/config.py` (authoritative), and **
344344
| `ACTIVITY_REPORT_ENABLED` | `false` | Post one chat event per account per day. **Off by default** — terms forbid tampering |
345345
| `ACTIVITY_REPORT_HOUR` | `10` | Activity hour (local Beijing time) |
346346
| `ALERT_ENABLED` | `true` | Operations-alert evaluation (pool exhausted / repeated task failures / token near expiry / error-rate spike), persisted to `alert_events` |
347-
| `ALERT_WEBHOOK_URL` | empty | Webhook POST (JSON), comma-separated (≤16, `http(s)` only, private/loopback/link-local blocked against SSRF), concurrent delivery, all must succeed (15s budget); empty = in-app records only |
347+
| `ALERT_WEBHOOK_URL` | empty | Webhook POST (JSON), comma-separated (≤16, `http(s)` only, private/loopback/link-local blocked against SSRF, re-checked against DNS resolution right before delivery and skipped if it fails to resolve), concurrent delivery, all must succeed (15s budget); empty = in-app records only |
348348
| `ALERT_INTERVAL_MINUTES` | `5` | Alert evaluation cycle (floor 1) |
349349
| `ALERT_SILENCE_MINUTES` | `30` | Silence window per `(rule, target)`; `0` disables |
350350
| `ALERT_POOL_READY_MIN` | `1` | Usable-credential threshold; `0` disables the rule |

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ CodeBuddy 成长中心的「连登天数 / 活跃地图」按日统计客户端
372372
| `ACTIVITY_REPORT_ENABLED` | `false` | 活跃上报(CodeBuddy 与国际版):每天为账号补发一条对话事件续连登。**默认关闭**——条款禁止篡改活动数据,上游改版即失效 |
373373
| `ACTIVITY_REPORT_HOUR` | `10` | 活跃上报的本地(北京)时间整点窗口;仅在开关开启时生效 |
374374
| `ALERT_ENABLED` | `true` | 运维告警开关:后台周期评估四类风险(池耗尽 / 任务连续失败 / token 临近到期 / 上游错误率骤升),命中落 `alert_events` |
375-
| `ALERT_WEBHOOK_URL` | 空 | 告警 Webhook:命中时 POST JSON,多个用逗号分隔(≤16 个,仅 `http(s)`,拒内网/回环/链路本地/保留地址防 SSRF),并发投递、全部成功才算成功(总预算 15s);留空 = 只留站内记录 |
375+
| `ALERT_WEBHOOK_URL` | 空 | 告警 Webhook:命中时 POST JSON,多个用逗号分隔(≤16 个,仅 `http(s)`,拒内网/回环/链路本地/保留地址防 SSRF,投递前再按 DNS 解析复核一次、解析失败即跳过),并发投递、全部成功才算成功(总预算 15s);留空 = 只留站内记录 |
376376
| `ALERT_INTERVAL_MINUTES` | `5` | 运维告警评估周期(下限 1 分钟) |
377377
| `ALERT_SILENCE_MINUTES` | `30` | 同一 `(规则, 对象)` 的静默窗:窗口内只落库 / 推送一次;`0` 关闭静默 |
378378
| `ALERT_POOL_READY_MIN` | `1` | 池耗尽阈值:可用凭证数少于该值时告警;`0` 关闭该规则 |

‎SECURITY.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# 安全策略
2+
3+
## 报告漏洞
4+
5+
请**不要**为安全问题开公开 Issue。用 GitHub 的
6+
[私密漏洞报告](https://github.com/RobbsLuo/Coding2API/security/advisories/new)
7+
提交,或直接邮件联系维护者(见仓库提交历史 / LICENSE 中的作者)。
8+
9+
请附上:
10+
11+
- 受影响的版本或 commit(`git rev-parse HEAD`)
12+
- 复现步骤与最小 PoC
13+
- 影响面(能否未授权访问、能否读写凭证、能否打内网)
14+
- 你希望公开的时间点(如有)
15+
16+
## 响应
17+
18+
单人维护项目,尽力而为:3 个工作日内确认收到,能修的尽快修,修复随常规
19+
release 发布并在 CHANGELOG / commit message 里说明。
20+
21+
## 部署方须知(本项目的信任边界)
22+
23+
这个服务把上游账号凭证加密存在本地,并暴露管理台。部署时请确保:
24+
25+
- `APP_SECRET` 是随机且保密的:它既是会话签名密钥,也是凭证加密密钥的派生源
26+
(见 `src/db/crypto.py`)。泄露等同于凭证泄露。
27+
- 管理台不要裸暴露到公网。若必须暴露,前面套一层带鉴权/限流的反向代理,
28+
并确保 `PUBLIC_BASE_URL` 与 `ALLOWED_HOSTS` 配置正确(见 README「配置」)。
29+
- `data/`(SQLite,含凭证密文与审计)、`secrets/users.txt`(口令哈希)不得进入
30+
备份之外的任何地方;服务启动会把 `data/` 目录权限收到 `0700`。
31+
- 告警 webhook 地址由 admin 配置:写入期与**投递前**都会拒绝指向内网/回环/
32+
链路本地(云元数据)的地址,解析失败按阻断处理。
33+
- 安全扫描在 CI 中执行:bandit(中危+)、pip-audit、`pnpm audit --prod`。
34+
35+
已知与已评估的非问题(扫描器误报口径)见 `PROPOSAL.md` 的安全审查章节。

‎TECHNICAL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,7 @@ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/api/users
580580
- **同步 sqlite3 而非 aiosqlite**:本地微秒级操作,asyncio 封装开销大于收益。
581581
- **双 httpx 客户端**:聊天流 `read=None` 防长流截断;短请求总超时 30s 防悬挂;共享 `trust_env=False`。非流式由引擎聚合同一流式上游(无独立 HTTP),悬挂兜底是引擎层聚合整体超时(`UPSTREAM_COMPLETE_TIMEOUT_SECONDS`,默认 600s,超时按瞬态错误换号重试)与事件条数上限(`complete_max_events`,防「快而无穷」)。
582582
- **手写 SQL 而非 ORM**:11 张表规模下 ORM 收益为负。
583-
- **polling OAuth 不转回调**:上游协议决定;TRAE 回调走主端口 + `PUBLIC_BASE_URL`。`/authorize` 无鉴权(浏览器 302 不带 key),防滥用靠两条:无进行中登录一律拒绝;待完成登录 600s TTL(长期挂着的 pending 会被同网络任何人用自己的 refreshToken 兑换——凭证入池、归属记为发起登录的管理员)。`app.state` 只保留 pending 的 state,不驻留含 refreshToken 的完整回调 URL。
583+
- **polling OAuth 不转回调**:上游协议决定;TRAE 回调走主端口 + `PUBLIC_BASE_URL`。`/authorize` 无鉴权(浏览器 302 不带 key),防滥用靠三条:无进行中登录一律拒绝;待完成登录 600s TTL(长期挂着的 pending 会被同网络任何人用自己的 refreshToken 兑换——凭证入池、归属记为发起登录的管理员);回调必须带**与本次 pending 匹配的 `loginTraceID`**(TRAE 原样回显登录 URL 里的 `(machine_id + device_id)[-16:]`,只有发起那次拿得到)。**刻意不用 Origin/Referer 判同源**:TRAE 回调本就由 `www.trae.cn` 跨源 302 跳来,https 部署下还会因协议降级 / `Referrer-Policy` 丢头,按 Referer 判既拦不住真攻击(加 `no-referrer` 即绕过)又可能误杀真回调。`app.state` 只保留 pending 的 state,不驻留含 refreshToken 的完整回调 URL。
584584
- **Responses / Anthropic 出口只做各自客户端的子集**:不做 `store=true` / `previous_response_id`(服务端无状态);Responses 的 `include=["reasoning.encrypted_content"]` 接受并忽略,终止事件不发 `[DONE]` 哨兵。Responses 出口经官方 SDK 契约验证并对真实 CB 上游冒烟,**未经真实 Codex CLI 端到端验证**。
585585
- **effort 档位映射不做,但缺省补值**:客户端给什么档位上游都接受,硬编码模型白名单与实际命名无关;但客户端**完全不发** `reasoning_effort` 时上游退化成「可见推演」,故 `stream_chat` 用 `setdefault` 补 `medium`——只补缺省,不映射档位、不覆盖显式值。
586586
- **Zen / Kilo 无凭证渠道用「虚拟凭证行」而非特判**:调度 / 冷却 / 统计 / 粘性全部按 `credentials` 行工作,种一条空凭证(`added_by="system"`)比在每个环节加「无凭证 provider」分支代价小,也让它天然出现在管理台凭证池里可暂停 / 探测 / 删除。`probe_quota` 恒 `probe_failed=True`(health `None` = 未知,**不是耗尽**)。种子只在默认装配路径执行且幂等;删除后重启复活是刻意的。

‎pyproject.toml‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "coding2api"
33
version = "0.4.3"
4-
description = "把 CodeBuddy 与 TRAE SOLO 上游通道统一封装为 OpenAI 兼容 API"
4+
description = "把 CodeBuddy(国内站 + 国际版)、TRAE SOLO、OpenCode Zen、Kilo Gateway、Qoder、CodeArts 七个上游渠道统一封装为 OpenAI / Responses / Anthropic 兼容 API"
55
readme = "README.md"
66
requires-python = ">=3.12"
77
dependencies = [
@@ -56,3 +56,11 @@ target-version = "py312"
5656
select = ["E", "F", "I", "UP", "B", "SIM"]
5757
# FastAPI 的 Depends() 默认参数写法是官方推荐的,B008 为误报
5858
ignore = ["B008"]
59+
60+
[tool.bandit]
61+
# B608(SQL 字符串拼接)在本仓全部是**白名单拼接**:分组键过 `_GROUP_KEYS`、
62+
# 排序走 `sql_order`、backfill 是字面量列名,值一律参数化。逐处 `# nosec`
63+
# 只会让 15 个真实告警被噪声淹没,故在配置层统一关闭并在此留档;新增拼接点
64+
# 仍须过白名单(见 tests/stats/test_query.py::test_group_methods_reject_*)。
65+
skips = ["B608"]
66+

0 commit comments

Comments
 (0)