fix(api): API 全量巡检 B1–B6 修复 server_ids 上限与 pending 审计

限制健康检查/推送/脚本执行的 server_ids 规模,补齐 pending 重试失败与删除审计,并附分批巡检报告。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-06 23:57:46 +08:00
parent ac17f91106
commit 722917bfc9
13 changed files with 358 additions and 4 deletions
@@ -0,0 +1,26 @@
# 2026-06-06 — API 巡检 B1servers + webssh)修复
## 摘要
完成 servers/webssh 模块 8 维安全审计;修复健康检查 `server_ids` 无上限与 pending 重试/删除审计缺口。
## 动机
按计划 B1→B6 API 巡检,servers 大部 CRUD 此前未 BUG 通读;需消除可观测的资源耗尽与审计不一致。
## 涉及文件
- `server/api/schemas.py``ServerCheck.server_ids` max_length=50
- `server/api/servers.py` — pending retry 失败审计、delete pending detail
- `tests/test_security_unit.py` — ServerCheck 上限回归
- `docs/reports/2026-06-06-api-audit-b1-servers-webssh.md`
## 迁移 / 重启
无;重启 API 即可。
## 验证
```bash
.venv/bin/pytest tests/test_security_unit.py tests/test_credential_poller.py -q
```
@@ -0,0 +1,17 @@
# 2026-06-06 — API 巡检 B2sync + files)修复
## 摘要
审计 sync_v2 / files / 路径校验模块;为 `SyncFiles``SyncVerify``server_ids` 增加 2000 上限,防止超大列表资源耗尽。
## 涉及文件
- `server/api/schemas.py`
- `tests/test_security_unit.py`
- `docs/reports/2026-06-06-api-audit-b2-sync-files.md`
## 验证
```bash
.venv/bin/pytest tests/test_security_unit.py -q
```
@@ -0,0 +1,21 @@
# 2026-06-06 — API 全量巡检 B1B6 closure
## 摘要
按 8 维清单完成 12 个 API 文件 ~122 端点巡检;修复 3 处 `server_ids` 无上限与 pending 审计缺口;0 P0/P1。
## 修复文件
- `server/api/schemas.py` — ServerCheck(50)、SyncFiles/SyncVerify/ScriptExecute(2000)
- `server/api/servers.py` — pending retry 失败审计、delete detail
- `tests/test_security_unit.py` — 回归用例
## 报告
`docs/reports/2026-06-06-api-audit-*.md` + [closure](docs/reports/2026-06-06-api-audit-closure.md)
## 验证
```bash
.venv/bin/pytest tests/test_security_unit.py tests/test_auth_refresh_reuse.py tests/test_credential_poller.py -q
```
@@ -0,0 +1,84 @@
# API 巡检 B1 — servers.py + webssh.py
**日期**: 2026-06-06
**范围**: `server/api/servers.py`28 端点)、`server/api/webssh.py`1 WS
**方法**: 8 维清单(认证/越权/注入/路径/凭据/输入/错误/审计)
## 结论
| 级别 | 数量 | 说明 |
|------|------|------|
| P0 | 0 | — |
| P1 | 0 | — |
| P2 | 1 | 已修:`ServerCheck.server_ids` 无上限 → DoS |
| H | 3 | 已记录/接受,见下表 |
**批次结论**: **PASS**(修复已合入工作区)
---
## 端点覆盖
全部 29 个路由均要求 `get_current_admin`REST)或 WebSSH 专用 JWT`purpose=webssh` + `server_id` 绑定)。
| 模块 | 端点 | 认证 | 审计 CUD | 备注 |
|------|------|------|----------|------|
| servers | CRUD `/` `/{id}` | JWT | ✓ | `_server_to_dict` 不返明文密码/私钥 |
| servers | `/stats` `/categories` `/logs` | JWT | — | 只读;stats 有 online 封顶 |
| servers | `/import` | JWT | ✓ | 1MB/500 行;CSV 注入防护 `_sanitize_csv_cell` |
| servers | `/batch/*` | JWT | ✓ | `server_ids` max 50SSH `shlex.quote` |
| servers | `/add-by-ip` `/pending/*` | JWT | ✓(已补 retry 失败) | 轮询失败写 pending + 审计 |
| servers | `/agent-key` `/agent-install-cmd` | JWT + 密码复核 | ✓ | 不回落全局 API_KEY |
| servers | `/install-agent` | JWT | ✓ | 服务端 SSH 可用全局 KEY 作 fallbackH |
| servers | `/check` | JWT | — | **已修** max 50 server_ids |
| webssh | `/ws/terminal/{id}` | JWT query | connect/disconnect | server_id 绑定;命令审计 + 危险命令检测 |
---
## FINDING 明细
### P2-01 — `POST /api/servers/check` 无 `server_ids` 上限(已修)
- **风险**: 传入数千 ID 触发并行 SSH 探测,资源耗尽。
- **修复**: `ServerCheck.server_ids` 增加 `max_length=50`(与 `BatchAgentAction` 一致)。
- **文件**: `server/api/schemas.py`
### H-01 — WebSSH 遗留 token 无 `tv` 字段时回退 `updated_at` 宽限
- **位置**: `webssh.py` `_verify_webssh_token`
- **影响**: 改密后 5 秒内旧 WebSSH token 仍可能有效(新签发 token 均含 `tv`)。
- **处置**: 接受(单人运维模型);新 token 路径已安全。
### H-02 — `install-agent` 无 per-server key 时回落 `settings.API_KEY`
- **位置**: `servers.py` `install_agent_remote`
- **影响**: 仅用于 SSH 下发安装命令,不向 API 响应暴露全局 KEY。
- **处置**: 接受;与 Agent 安装运维模型一致。
### H-03 — `retry_pending` 失败与 `delete_pending` 审计字段不完整(已修)
- **修复**: 失败重试写 `retry_pending_server_failed` 审计;删除前读取 pending 写入 `detail`
- **文件**: `server/api/servers.py`
---
## 安全亮点(无需改动)
- `_server_to_dict`: `password_set` / `ssh_key_private_set` / agent key 预览
- `update_server`: `ALLOWED_FIELDS` 白名单,禁止 mass-assign `agent_api_key`
- `_sudo_wrap`: 临时 sudoers 白名单命令 + 事后清理
- `setup-files-sudo`: 密码经 stdin 喂 `sudo -S`,不进 argv
- WebSSH: token `server_id` 与路径参数必须一致(防 IDOR
- CSV import: 大小/行数限制 + formula injection 防护
---
## 验证
```bash
.venv/bin/pytest tests/test_credential_poller.py tests/test_security_unit.py -q
```
---
**下一批**: B2 — `sync_v2.py` + `files.py` + `remote_path_validation.py`
@@ -0,0 +1,58 @@
# API 巡检 B2 — sync_v2.py + files.py + remote_path_validation.py
**日期**: 2026-06-06
**范围**: 22 个 `/api/sync/*` 端点 + `GET /api/files/browse` + 路径校验模块
## 结论
| 级别 | 数量 | 说明 |
|------|------|------|
| P0 | 0 | — |
| P1 | 0 | — |
| P2 | 1 | 已修:`SyncFiles`/`SyncVerify` `server_ids` 无上限 |
| H | 1 | `_audit_sync` 写失败仅 warning(不阻断业务) |
**批次结论**: **PASS**
---
## 覆盖摘要
| 检查项 | 结果 |
|--------|------|
| 全部 sync 端点 JWT | ✓ 22/22 `get_current_admin` |
| 远程路径 | ✓ `normalize_remote_abs_path` / `remote_join` |
| 本地 staging | ✓ `ensure_under_nexus_upload` 限制 `/tmp/nexus_upload_*` |
| Shell 拼接 | ✓ 普遍 `shlex.quote` |
| 上传限制 | ✓ 单文件 100MBZIP 500MB |
| 剪贴板 copy/move | ✓ `assert_clipboard_transfer_safe` 防 dest 在源子树内 |
| files GET browse | ✓ ETag + 路径规范化 + 审计 |
---
## FINDING
### P2-02 — `SyncFiles` / `SyncVerify` `server_ids` 无 max_length(已修)
- **风险**: 超大列表导致海量协程调度与 DB 查询(verify 虽 Semaphore(5) 仍会创建 N 个 task)。
- **修复**: `max_length=2000`(对齐平台子机规模上限)。
- **文件**: `server/api/schemas.py`
### H-04 — `_audit_sync` 异常时 `logger.warning` 不向上抛
- **位置**: `sync_v2.py` `_audit_sync`
- **处置**: 接受 — 审计失败不应阻断文件操作;已有 exc_info 日志。
---
## 安全亮点
- `file-ops` delete 使用 `rm -rf` 但路径经 `normalize_remote_abs_path` 校验
- `upload` 文件名剥离 `/` `\`
- `local-file-ops` rename 禁止 `..` / `.`
- `write-file` 5MB 上限 + etag 乐观锁(409
- `read-file` 1MB 默认上限
---
**下一批**: B3 — auth + auth_jwt + dependencies
@@ -0,0 +1,18 @@
# API 巡检 B3 — auth.py + auth_jwt.py + dependencies.py
**日期**: 2026-06-06
## 结论: **PASS**0 P0/P1/P2,无代码修改)
| 模块 | 端点/职责 | 结果 |
|------|-----------|------|
| `auth.py` | login/refresh/logout/TOTP/password/webssh-token/me | ✓ refresh HttpOnly cookie;改密/TOTP 需 JWT |
| `auth_jwt.py` | JwtAuthMiddleware + `get_current_admin` | ✓ PUBLIC_PREFIXES 白名单;`tv` 版本校验 |
| `dependencies.py` | DI + `check_dangerous_command` | ✓ 无 session 泄漏;危险命令 warning 日志 |
## 要点
- 登录失败 → 429 锁定(`auth_service`
- Refresh 重用检测 + `token_version` 递增(`test_auth_refresh_reuse.py` 覆盖)
- WebSSH token 独立 `purpose=webssh` + `server_id` 绑定
- `check_dangerous_command` 为**警告不阻断**(单人运维接受,与 Scripts/WebSSH 一致)
@@ -0,0 +1,19 @@
# API 巡检 B4 — scripts.py + terminal.py
**日期**: 2026-06-06
## 结论: **PASS**P2×1 已修)
### P2-03 — `ScriptExecute.server_ids` 无上限(已修)
- **修复**: `max_length=2000``schemas.py`
- 引擎内部分批 50/批、并发 10,与文档一致
### 其他
| 检查 | 结果 |
|------|------|
| JWT 全覆盖 | ✓ |
| `check_dangerous_command` on exec | ✓ 警告级 |
| 长任务回调 HTTPS 校验 | ✓ `master_callback_url` 单测 |
| terminal quick-commands CRUD | ✓ JWT + 审计 |
@@ -0,0 +1,16 @@
# API 巡检 B5 — settings.py + search.py + assets.py
**日期**: 2026-06-06
## 结论: **PASS**(无代码修改)
| 模块 | 安全要点 | 结果 |
|------|----------|------|
| `settings.py` | reveal API/preset/ssh-key/telegram 均需 `_verify_reauth` | ✓ |
| `settings.py` | 不可改 `SECRET_KEY`/`API_KEY`/`ENCRYPTION_KEY`/`DATABASE_URL` | ✓ 指南约束 |
| `search.py` | `_escape_like` 防 SQL LIKE 注入 | ✓ |
| `assets.py` | nodes/platforms/command-logs/ssh-sessions | ✓ 全 JWT |
### H — bing-wallpapers 公开 GET
- 已在 `PUBLIC_PREFIXES`;只读壁纸 URL,无敏感数据(与 2026-06-04 closure 一致)
@@ -0,0 +1,16 @@
# API 巡检 B6 — agent.py + install.py + websocket.py + health.py
**日期**: 2026-06-06
## 结论: **PASS**(无代码修改)
| 模块 | 认证 | 结果 |
|------|------|------|
| `agent.py` | `X-API-Key`(全局或 per-server | ✓ 心跳/script-callback 鉴权 |
| `install.py` | 仅安装模式;已安装 → 403 | ✓ 历史 F2a-08/09 已修 |
| `websocket.py` | `/ws/` 在 PUBLIC_PREFIXESsync/alert 内二次校验 token/batch_id | ✓ |
| `health.py` | `/health` 公开 | ✓ 无敏感信息 |
### 安装向导边界
- 正常模式所有 `/api/install/*` 返回 403(除 status 探测逻辑在 install 模块内处理)
@@ -0,0 +1,43 @@
# API 全量巡检 — 总 Closure
**日期**: 2026-06-06
**计划**: B1→B6 按 8 维清单(认证/越权/注入/路径/凭据/输入/错误/审计)
**策略**: 审计 + 当场修复(用户确认)
## 批次索引
| 批 | 模块 | 报告 | P0 | P1 | P2 已修 |
|----|------|------|----|----|---------|
| B1 | servers + webssh | [b1](2026-06-06-api-audit-b1-servers-webssh.md) | 0 | 0 | 1 |
| B2 | sync + files | [b2](2026-06-06-api-audit-b2-sync-files.md) | 0 | 0 | 1 |
| B3 | auth | [b3](2026-06-06-api-audit-b3-auth.md) | 0 | 0 | 0 |
| B4 | scripts + terminal | [b4](2026-06-06-api-audit-b4-scripts-terminal.md) | 0 | 0 | 1 |
| B5 | settings + search + assets | [b5](2026-06-06-api-audit-b5-settings-search-assets.md) | 0 | 0 | 0 |
| B6 | agent + install + ws + health | [b6](2026-06-06-api-audit-b6-agent-install-ws-health.md) | 0 | 0 | 0 |
## 修复汇总
| ID | 问题 | 修复 |
|----|------|------|
| P2-01 | `ServerCheck.server_ids` 无上限 | `max_length=50` |
| P2-02 | `SyncFiles`/`SyncVerify.server_ids` 无上限 | `max_length=2000` |
| P2-03 | `ScriptExecute.server_ids` 无上限 | `max_length=2000` |
| H-03 | pending retry 失败/删除审计不完整 | 补 audit + detail |
## 已接受 H(不阻断)
- WebSSH 遗留 token 无 `tv` 宽限 5s
- `install-agent` 回落全局 `API_KEY`(仅 SSH 下发,不返前端)
- `_audit_sync` 写失败仅日志
- `check_dangerous_command` 警告不阻断执行
## 验证
```bash
.venv/bin/pytest tests/test_security_unit.py tests/test_auth_refresh_reuse.py tests/test_credential_poller.py -q
```
## Changelog
- `docs/changelog/2026-06-06-api-audit-b1-servers-webssh.md`
- `docs/changelog/2026-06-06-api-audit-b2-sync-files.md`
+4 -4
View File
@@ -104,7 +104,7 @@ class ServerUpdate(BaseModel):
class ServerCheck(BaseModel):
server_ids: List[int] = Field(..., min_length=1)
server_ids: List[int] = Field(..., min_length=1, max_length=50)
class ServerImportResult(BaseModel):
@@ -148,7 +148,7 @@ class FileUpload(BaseModel):
# ── Sync ──
class SyncFiles(BaseModel):
server_ids: List[int] = Field(..., min_length=1)
server_ids: List[int] = Field(..., min_length=1, max_length=2000)
source_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
sync_mode: str = Field("incremental", pattern="^(incremental|full|overwrite|checksum)$")
@@ -299,7 +299,7 @@ class ReconcileStaleLogs(BaseModel):
class SyncVerify(BaseModel):
"""Post-push verification: compare local source with remote target via md5sum."""
server_ids: List[int] = Field(..., min_length=1)
server_ids: List[int] = Field(..., min_length=1, max_length=2000)
source_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
max_files: int = Field(200, ge=1, le=500)
@@ -467,7 +467,7 @@ class ScriptUpdate(BaseModel):
class ScriptExecute(BaseModel):
script_id: Optional[int] = None
command: str = Field(..., min_length=1)
server_ids: List[int] = Field(..., min_length=1)
server_ids: List[int] = Field(..., min_length=1, max_length=2000)
credential_id: Optional[int] = None
timeout: int = Field(60, ge=1, le=600)
long_task: bool = False
+15
View File
@@ -1169,6 +1169,17 @@ async def retry_pending_server(
pending.last_attempt_at = datetime.now(timezone.utc)
await pending_repo.update(pending)
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="retry_pending_server_failed",
target_type="pending_server",
target_id=pending.id,
detail=f"凭据轮询重试失败 地址={pending.domain}:{pending.port or 22}{pending.attempts}",
ip_address=ip_address,
))
return {
"success": False,
"pending_id": pending.id,
@@ -1187,6 +1198,9 @@ async def delete_pending_server(
from server.infrastructure.database.pending_server_repo import PendingServerRepositoryImpl
repo = PendingServerRepositoryImpl(db)
pending = await repo.get_by_id(pending_id)
if not pending:
raise HTTPException(404, "待连接服务器不存在")
if not await repo.delete(pending_id):
raise HTTPException(404, "待连接服务器不存在")
@@ -1197,6 +1211,7 @@ async def delete_pending_server(
action="delete_pending_server",
target_type="pending_server",
target_id=pending_id,
detail=f"删除待连接 地址={pending.domain}:{pending.port or 22}",
ip_address=ip_address,
))
+21
View File
@@ -21,6 +21,27 @@ def test_dangerous_command_safe_empty():
assert check_dangerous_command("echo hello") == []
def test_server_check_server_ids_max_fifty():
from pydantic import ValidationError
from server.api.schemas import ServerCheck
ServerCheck(server_ids=list(range(1, 51)))
with pytest.raises(ValidationError):
ServerCheck(server_ids=list(range(1, 52)))
def test_sync_files_server_ids_max_two_thousand():
from pydantic import ValidationError
from server.api.schemas import SyncFiles, SyncVerify
SyncFiles(server_ids=list(range(1, 2001)), source_path="/tmp/nexus_upload_x/a")
SyncVerify(server_ids=[1], source_path="/tmp/nexus_upload_x/a")
with pytest.raises(ValidationError):
SyncFiles(server_ids=list(range(1, 2002)), source_path="/tmp/nexus_upload_x/a")
def test_parse_server_ids_invalid_json():
assert _parse_server_ids_json("not-json") == []