fix(api): API 全量巡检 B1–B6 修复 server_ids 上限与 pending 审计
限制健康检查/推送/脚本执行的 server_ids 规模,补齐 pending 重试失败与删除审计,并附分批巡检报告。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# 2026-06-06 — API 巡检 B1(servers + 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 巡检 B2(sync + 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 全量巡检 B1–B6 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 50;SSH `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 作 fallback(H) |
|
||||
| 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` |
|
||||
| 上传限制 | ✓ 单文件 100MB;ZIP 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_PREFIXES;sync/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`
|
||||
@@ -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
|
||||
|
||||
@@ -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,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") == []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user