fix(security): 外网攻击面加固 BL-01~05

收紧 PUBLIC 路径误匹配、强制 health/detail 与壁纸 sync 鉴权、默认关闭 OpenAPI;
安装锁定后 /api/install/* 统一 404;附探测脚本、测试与审计文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-07 01:45:10 +08:00
parent 722917bfc9
commit e5e2582743
18 changed files with 886 additions and 29 deletions
@@ -0,0 +1,39 @@
# Changelog — 外网攻击面安全巡检(E1–E6)
**日期**2026-06-06
## 摘要
对生产 `api.synaglobal.vip` 执行无凭证外网攻击面巡检:新增黑盒脚本、三份报告与加固 backlog;**无应用代码加固**audit_only)。
## 动机
在「已登录管理员可信」前提下,审计无 JWT / 弱认证暴露面,与 B1–B6 鉴权后 API 巡检互补。
## 涉及文件
| 类型 | 路径 |
|------|------|
| 脚本 | `scripts/security_probe_external.sh` |
| 报告 | `docs/reports/2026-06-06-external-attack-probe-production.md` |
| 报告 | `docs/reports/2026-06-06-external-attack-surface-audit.md` |
| Backlog | `docs/reports/2026-06-06-external-attack-hardening-backlog.md` |
## 主要发现(未修复)
- **P2**`GET /health/detail` 未授权可读组件状态(`PUBLIC_PREFIXES` `/health` 前缀过宽)
- **P2**`POST /api/settings/bing-wallpapers/sync` 未授权可触发外联下载(同上)
- **H**OpenAPI/Swagger/ReDoc 公网可访问
- **0 P0/P1**
## 迁移 / 重启
无。仅文档与探测脚本。
## 验证方式
```bash
NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
```
证据见 `docs/reports/2026-06-06-external-attack-probe-production.md`
@@ -0,0 +1,41 @@
# Changelog — 外网攻击面加固(BL-01BL-04
**日期**2026-06-06
## 摘要
修复外网巡检发现的 P2/H 问题:收紧 `PUBLIC_PREFIXES` 前缀误匹配、强制 `/health/detail` 与壁纸 sync 鉴权、生产默认关闭 OpenAPI/Swagger/ReDoc。
## 动机
生产探测 EXT-01/02/03`/health/detail``bing-wallpapers/sync` 无 JWT 可访问;全量 API schema 公网暴露。
## 涉及文件
| 文件 | 变更 |
|------|------|
| `server/api/auth_jwt.py` | 精确路径 + 方法感知 `_is_public_path``require_current_admin` |
| `server/api/health.py` | `health_detail` 使用 `require_current_admin` |
| `server/api/settings.py` | `bing_wallpapers_sync` 使用 `require_current_admin` |
| `server/config.py` | `EXPOSE_API_DOCS`(默认 `false` |
| `server/main.py` | `docs_url/redoc_url/openapi_url` 按配置关闭 |
| `tests/test_external_attack_surface_hardening.py` | 回归测试 |
## 配置
```env
# 仅本地开发需要时开启
NEXUS_EXPOSE_API_DOCS=true
```
## 迁移 / 重启
需重启 API 进程。无 DB 迁移。
## 验证
```bash
pytest tests/test_external_attack_surface_hardening.py -q
NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
# 部署后期望:health/detail 401bing sync 401openapi 404
```
@@ -0,0 +1,34 @@
# Changelog — Install 锁定后全端点 404BL-05
**日期**2026-06-07
## 摘要
已安装且 install 锁定时,`/api/install/*` 全部返回 **404 Not found**,不再返回 403 及「安装向导已锁定」等可指纹识别文案。
## 动机
外网攻击面巡检 BL-05:减少已部署实例的安装向导指纹泄露。
## 涉及文件
| 文件 | 变更 |
|------|------|
| `server/api/install.py` | `_raise_not_found_if_locked()``_reject_if_locked` / `lock_install` 统一 404 |
| `tests/test_install_locked_404.py` | 锁定端点回归测试 |
| `scripts/security_probe_external.sh` | 锁定态 install 探测仅接受 404 |
| `docs/reports/2026-06-06-external-attack-surface-audit.md` | §4.2 更新;§7 待定项 BL-0608 |
| `docs/reports/2026-06-06-external-attack-hardening-backlog.md` | BL-05 已实施 |
## 迁移 / 重启
需重启 API。无 DB 迁移。
## 验证
```bash
pytest tests/test_install_locked_404.py -q
NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
```
部署后期望:所有 `/api/install/*` 探测 → **404**
@@ -0,0 +1,101 @@
# 外网攻击面加固 Backlogaudit_only 产出)
**日期**2026-06-06
**来源**[`2026-06-06-external-attack-surface-audit.md`](2026-06-06-external-attack-surface-audit.md)
**状态**BL-01BL-05 **已实施**BL-06BL-08 **待定**(见总报告 [§7](2026-06-06-external-attack-surface-audit.md#7-待定加固项不排期)
---
## P2 — 建议优先
### BL-01 收紧 `PUBLIC_PREFIXES` 前缀匹配 ✅ 已实施
| 字段 | 内容 |
|------|------|
| 问题 | EXT-01 `/health/detail`、EXT-02 `bing-wallpapers/sync` 被前缀误放行 |
| 方案 A | 将 `/health` 改为精确路径列表;`/api/settings/bing-wallpapers` 仅匹配 GET(或拆路由前缀) |
| 方案 B | `_is_public_path` 改为精确匹配 + 显式允许方法 |
| 涉及文件 | `server/api/auth_jwt.py` |
| 验收 | 无 JWT 时 `GET /health/detail` → 401`POST .../sync` → 401 |
| 回滚 | 恢复 `PUBLIC_PREFIXES` 元组 |
### BL-02 `/health/detail` 强制鉴权(防御纵深) ✅ 已实施
| 字段 | 内容 |
|------|------|
| 问题 | 非 `/api/` 路径不走 JwtAuthMiddleware |
| 方案 | handler 内显式 `if admin is None: raise 401`;或把 detail 移到 `/api/health/detail` |
| 涉及文件 | `server/api/health.py``server/main.py` DbSessionMiddleware 路径列表 |
| 验收 | 生产探测 401 |
### BL-03 `bing_wallpapers_sync` 拒绝 `admin is None` ✅ 已实施
| 字段 | 内容 |
|------|------|
| 方案 | `Depends(get_current_admin)` 返回 None 时 raise 401(与 BL-01 双保险) |
| 涉及文件 | `server/api/settings.py` |
---
## H — 信息暴露与运维
### BL-04 生产关闭 OpenAPI / Swagger / ReDoc ✅ 已实施
| 字段 | 内容 |
|------|------|
| 问题 | EXT-03 全 API schema 公网可读 |
| 方案 A | `FastAPI(..., docs_url=None, redoc_url=None, openapi_url=None)` |
| 方案 B | Nginx/1Panel `location` deny 或 internal only |
| 验收 | `GET /openapi.json` → 404 |
| 备注 | 开发环境可保留文档 |
### BL-05 已安装后隐藏 install 指纹 ✅ 已实施
| 字段 | 内容 |
|------|------|
| 实现 | `_raise_not_found_if_locked()`;全部 install 端点锁定后 404 |
| 涉及文件 | `server/api/install.py``tests/test_install_locked_404.py` |
| 验收 | 锁定后所有 `/api/install/*` → 404 |
### BL-06 Agent per-server key 迁移 ⏸ 待定
| 字段 | 内容 |
|------|------|
| 问题 | global `API_KEY` legacy 回退(已 risk-acceptance |
| 方案 | 批量 `POST /api/servers/{id}/agent-key`;监控 warning 日志;最终禁用 global 回退 |
| 重评条件 | 见 `risk-acceptance-single-operator.md` §接受项 2 |
### BL-07 WebSocket 外网探测与反代 ⏸ 待定
| 字段 | 内容 |
|------|------|
| 问题 | 外网 WS 403,与应用 4001/4401 不一致 |
| 方案 | 核对 Nginx `proxy_set_header Upgrade`;内网或 `curl` upgrade 验证关闭码 |
| 备注 | WS query JWT 改造 ROI 低,维持 ADR-011 接受项 |
### BL-08 全站 IP 白名单(可选) ⏸ 待定
| 字段 | 内容 |
|------|------|
| 范围 | Nginx 层限制 `/api/*`(非仅 login |
| 风险 | 2000+ 子机 Agent 心跳需放行;需细粒度路径规则 |
---
## 验证清单(修复批次)
```bash
NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
# 期望:health/detail 401bing sync 401openapi 404(若 BL-04
bash scripts/local_verify.sh
```
---
## 不建议本轮做
| 项 | 原因 |
|----|------|
| WS ticket 首帧鉴权 | 单人运维 ROI 低,已接受 |
| 登录暴破 CAPTCHA | 已有 15min 锁定 |
| 关闭 `/agent/install.sh` 公网 | 破坏远程安装流程 |
@@ -0,0 +1,76 @@
# 外网黑盒探测记录 — 生产 api.synaglobal.vip
**日期**2026-06-06
**目标**`https://api.synaglobal.vip`
**脚本**`scripts/security_probe_external.sh`
**约束**:无 JWT、无真实 API Key、无登录暴破、无破坏性写操作(除只读探测外仅单次错误登录与假 Key 请求)
## 执行命令
```bash
NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
```
日志:`/tmp/nexus-external-probe/probe-20260606T160732Z.txt`
## 探测结果摘要
| 探测项 | 期望 | 实际 | 判定 |
|--------|------|------|------|
| `GET /health` | 200 纯文本 ok | 200 `ok` | PASS |
| `GET /health/detail` | 401 | **200** + redis/mysql/ws 状态 | **FAIL (P2)** |
| `GET /openapi.json` | 记录 | 200 全量 OpenAPI 3.1 | WARN (H) |
| `GET /docs` | 记录 | 200 Swagger UI | WARN (H) |
| `GET /redoc` | 记录 | 200 ReDoc | WARN (H) |
| `GET /api/install/status` | 404(已锁定) | 404 空 body | PASS |
| `GET /api/install/env-check` 等 | 403/404 | 403「安装向导已锁定」 | PASS |
| `POST /api/install/*` | 403 | 422(探测 body 不满足 Pydantic;锁定逻辑在合法 body 前亦会 403 | INFO |
| `GET /api/settings/bing-wallpapers` | 200 只读 URL | 200 urls[] | PASS |
| `POST /api/settings/bing-wallpapers/sync` | 401 | **200** + downloaded=8 | **FAIL (P2)** |
| `GET/POST /api/servers/` | 401 | 401 Missing Authorization | PASS |
| `GET /api/audit/` | 401 | 401 | PASS |
| `POST /api/agent/heartbeat` 无 Key | 拒绝 | 422(缺 Header | PASS(仍拒绝) |
| `POST /api/agent/heartbeat` 假 Key | 401 | 401 | PASS |
| `POST /api/agent/script-callback` 假 Key | 401/404 | 422(缺字段;带 Key 时预期 401/404 | PASS |
| `POST /api/auth/login` 错误用户 | 统一错误文案 | `用户名或密码错误` | PASS |
| `GET /agent/install.sh` | 200 | 200 安装脚本 | INFO(设计如此) |
| `WS /ws/alerts` 无 token | 4001 或拒绝 | **HTTP 403**(反代层) | INFO |
| `WS /ws/alerts` 假 token | 4401 或拒绝 | **HTTP 403** | INFO |
## 响应片段(关键 FINDING
### `/health/detail` 未授权可读
```http
GET /health/detail HTTP/1.1
Host: api.synaglobal.vip
HTTP/1.1 200 OK
{"status":"ok","checks":{"python":"ok","redis":"ok","mysql":"ok","websocket_clients":0}}
```
### `bing-wallpapers/sync` 未授权可触发外联同步
```http
POST /api/settings/bing-wallpapers/sync HTTP/1.1
Content-Type: application/json
```
### OpenAPI 可访问(节选)
`GET /openapi.json` → 200,含全部管理 API path(含 `/api/servers/pending/add-by-ip` 等)。
## WebSocket 说明
外网经 `wss://api.synaglobal.vip/ws/*` 握手在到达应用前被反代返回 **403**,无法在公网直接观测应用层 close code **4001/4401**。应用代码(`server/api/websocket.py`)在无 token 时设计为 4001。
## 结论
- **0 个 P0/P1**(未授权 RCE、未授权写库、未授权读密文)
- **2 个 P2**`/health/detail` 组件指纹泄露;`bing-wallpapers/sync` 未授权触发外联下载
- **多项 H**OpenAPI/Swagger/ReDoc 暴露;WS 外网 403 无法验证 ADR-011 关闭码
详见 [`2026-06-06-external-attack-surface-audit.md`](2026-06-06-external-attack-surface-audit.md)。
详见 [`2026-06-06-external-attack-surface-audit.md`](2026-06-06-external-attack-surface-audit.md)。
@@ -0,0 +1,189 @@
# 外网攻击面安全巡检报告(E1–E6)
**日期**2026-06-06
**范围**:生产 `https://api.synaglobal.vip` 无 JWT / 弱认证暴露面
**信任边界**:已登录管理员视为可信;本轮不审计持 JWT 后的管理操作
**模式**audit_only(仅报告与 backlog**本轮不修改应用代码**)
**黑盒证据**[`2026-06-06-external-attack-probe-production.md`](2026-06-06-external-attack-probe-production.md)
---
## 1. 攻击面资产表(E1
来源:`server/api/auth_jwt.py` `PUBLIC_PREFIXES``server/main.py` 路由与静态挂载、OpenAPI 交叉核对。
### 1.1 JWT 中间件跳过路径(`PUBLIC_PREFIXES`
| 前缀/路径 | 方法 | 二次鉴权 | 说明 |
|-----------|------|----------|------|
| `/api/auth/login` | POST | 无 | 暴破/枚举面;15min 锁定 |
| `/api/auth/refresh` | POST | HttpOnly cookie | 刷新令牌 |
| `/api/auth/logout` | POST | body refresh | 吊销 refresh |
| `/api/agent/*` | POST | `X-API-Key` + per-server | heartbeat、script-callback |
| `/api/install/*` | GET/POST | 安装锁 + install_token | 已锁定除 status 外 403 |
| `/api/settings/bing-wallpaper(s)` | GET | 无 | 只读壁纸 URL |
| `/api/settings/bing-wallpapers/sync` | POST | **应为 JWT**;见 §2 FINDING | 前缀匹配导致误放行 |
| `/health` | GET | 无 | 纯文本 `ok` |
| `/health/detail` | GET | **应为 JWT**;见 §2 FINDING | 前缀 `/health` 误匹配 |
| `/ws/*` | WS | Query JWT / WebSSH token | ADR-011 |
| `/openapi.json` `/docs` `/redoc` | GET | 无 | FastAPI 默认文档 |
| 其余 `/api/*` | * | JwtAuthMiddleware Bearer | 401 无 token |
**注意**`JwtAuthMiddleware` 仅拦截 `path.startswith("/api/")``/health/detail` 不走中间件,完全依赖 `Depends(get_current_admin)`,与 `PUBLIC_PREFIXES` 前缀匹配叠加后失效。
### 1.2 静态与前端壳
| 路径 | 认证 | 文件 |
|------|------|------|
| `/app/*` | 无(SPA);`AppAuth` 仅校验 refresh cookie 格式 | `web/app/` |
| `/agent/install.sh` `agent.py` `agent.sh` `uninstall.sh` | 无 | `web/agent/` |
| `/app/install.html` | 安装模式;锁定后归档为 `.bak` | 安装向导 |
### 1.3 Install API 端点(`prefix=/api/install`
| 端点 | 方法 | 已锁定生产实测 |
|------|------|----------------|
| `/status` | GET | **404**`_is_locked()` |
| `/env-check` | GET | 403 |
| `/connection-check` | GET | 403 |
| `/state` | GET | 403 |
| `/test-credentials` | POST | 403(合法 body |
| `/init-db` | POST | 403 |
| `/create-admin` | POST | 403 + install_token |
| `/lock` | POST | 403 |
### 1.4 Agent API
| 端点 | 认证链 |
|------|--------|
| `POST /api/agent/heartbeat` | Header `X-API-Key``_verify_server_api_key(server_id)` |
| `POST /api/agent/script-callback` | 同上 + `job_id`/`secret` + Redis 限速 |
控制面**无** `/api/agent/exec`P0 RCE 已移除,注释见 `agent.py:378`)。
---
## 2. 生产黑盒探测(E2
见 [`2026-06-06-external-attack-probe-production.md`](2026-06-06-external-attack-probe-production.md)。
### FINDING 汇总
| ID | 级别 | 问题 | 根因(代码) |
|----|------|------|--------------|
| EXT-01 | **P2** | `GET /health/detail` 无 JWT 返回组件状态 | `PUBLIC_PREFIXES``/health``_is_public_path` 使 `get_current_admin` 无凭证返回 `None`handler 未校验 admin |
| EXT-02 | **P2** | `POST /api/settings/bing-wallpapers/sync` 无 JWT 可触发 Bing 下载 | 同上:`/api/settings/bing-wallpapers` 前缀匹配 `/sync` 子路径 |
| EXT-03 | **H** | `/openapi.json` `/docs` `/redoc` 公网可访问 | `main.py` 未设 `docs_url=None`;前缀在 PUBLIC |
| EXT-04 | **H** | 外网 WS 握手 HTTP 403,无法验证 4001/4401 | 反代/Nginx 规则;应用层逻辑见 `websocket.py` |
| — | PASS | 管理 API、假 Agent Key、锁定 install | JwtAuthMiddleware / `_reject_if_locked` |
**P0/P10**(无未授权 RCE、写库、读密文、重装成功)。
---
## 3. Agent M2M 滥用链(E3
### 3.1 认证逻辑
```mermaid
sequenceDiagram
participant Attacker
participant API as Nexus_API
participant Redis
participant WS as WebSocket_clients
Note over Attacker: 持有泄露的 global API_KEY
Attacker->>API: POST /api/agent/heartbeat<br/>X-API-Key + server_id=N
API->>API: server 存在且无 agent_api_key?
alt legacy 服务器
API->>Redis: HSET heartbeat:N
API->>WS: broadcast_alert 若超阈值
API-->>Attacker: 200 ok
else 已有 per-server key
API-->>Attacker: 401
end
```
- **全局 Key 回退**`server/api/agent.py:84-92` — 仅当服务器**未**设置 `agent_api_key` 时接受 `settings.API_KEY`
- **影响**:伪造心跳 → 污染在线状态/告警/Telegram**非**控制面 RCEexec 在子机 `web/agent/agent.py`)。
- **script-callback**:需有效 `job_id` + `secret`(一次性);`check_script_callback_rate` 每 job 10/min、每 IP 60/min`script_callback_rate.py`)。
- **生产探测**:假 Key → **401**
**与 risk-acceptance 关系**global Key legacy 仍为**接受项**;外网视角下 Key 泄露 + 未迁移服务器 = P2 伪造状态,维持文档化接受,见 [`risk-acceptance-single-operator.md`](../project/risk-acceptance-single-operator.md) §接受项 2。
---
## 4. 安装向导边界(E4
### 4.1 令牌逻辑(代码)
- `_verify_install_token``secrets.compare_digest` + 状态文件 `install_token``install.py:933-946`)。
- 无状态文件 → 403;暴力需猜 256-bit 级 token**不可行**。
- `create-admin` / `lock` 需 token 或已完成步骤。
### 4.2 已安装 + 锁定(生产)
- **BL-052026-06-07**:全部 `/api/install/*`**404** `Not found`(含 status、env-check、init-db、lock 等),无中文指纹。
- **无法**外网重装(init-db/create-admin 被锁)。
### 4.3 残余 H
- 未锁定环境下 `/status` 会返回 `installed/locked/configured`(仅安装模式相关)。
- OpenAPI 仍暴露 install 路径 schemaEXT-03)。
---
## 5. 认证与会话(E5
| 项 | 结论 |
|----|------|
| 登录暴破 | `MAX_LOGIN_FAILURES` + 15min 锁定(`auth_service.py:122-130`);**本轮未跑循环暴破** |
| IP allowlist | **仅 `login()`**`auth_service.py:98-120`);拿到 JWT 后**不受** allowlist 约束 |
| 用户名枚举 | 生产单次错误登录:`用户名或密码错误`(不存在与密码错误文案统一)— PASS |
| Refresh | HttpOnly + Secure + SameSite=Lax;重用检测见 `tests/test_auth_refresh_reuse.py` |
| Logout CSRF | SameSite=Lax 降低跨站 POST 风险 |
**外网主要面**`/api/auth/login` 暴破(有锁定)、refresh cookie 窃取(需 XSS/本机恶意软件)。
---
## 6. 总结与验收
| 验收项 | 状态 |
|--------|------|
| 生产黑盒脚本 + 证据 | ✅ `scripts/security_probe_external.sh` + probe 报告 |
| PUBLIC_PREFIXES + static 覆盖 | ✅ §1 |
| 0 未解释 P0/P1 | ✅ |
| 加固 backlog 独立 | ✅ [`2026-06-06-external-attack-hardening-backlog.md`](2026-06-06-external-attack-hardening-backlog.md) |
### 分级统计
- **P0**0
- **P1**0
- **P2**2EXT-01、EXT-02
- **H**2+EXT-03、EXT-04、OpenAPI 指纹、Agent legacy 已接受)
### 与 B1B6 API 巡检关系
| 已完成 B 轮 | 本轮 E 轮 |
|-------------|-----------|
| 鉴权后逻辑、输入上限 | 无 JWT 暴露面 |
| 当场修复若干 schema 上限 | audit_only,修复进 backlog |
---
## 7. 待定加固项(不排期)
以下项已记入 [`2026-06-06-external-attack-hardening-backlog.md`](2026-06-06-external-attack-hardening-backlog.md)**本轮不实施**
| ID | 项 | 说明 |
|----|-----|------|
| BL-06 | Agent per-server key 迁移 | 存量子机逐步分配 `agent_api_key`,监控 global `API_KEY` legacy 回退日志;见 [`risk-acceptance-single-operator.md`](../project/risk-acceptance-single-operator.md) §接受项 2 |
| BL-07 | WebSocket 反代核对 | 生产 Nginx `/ws/``Upgrade`/`Connection` 配置;外网探测曾得 HTTP 403 而非应用层 4001/4401 |
| BL-08 | 全站 IP 白名单(可选) | Nginx 层限制 `/api/*`;须为 `/api/agent/*` 单独放行(2000+ 子机心跳) |
**已实施加固**BL-01BL-05(见 backlog 与 changelog)。
---
**下一步**:部署 BL-05 后重跑 `security_probe_external.sh`;BL-06~08 按运维需要再排期。
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# security_probe_external.sh — Read-only external attack-surface probes (no credentials).
# Usage: NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
set -euo pipefail
BASE="${NEXUS_PROBE_BASE:-https://api.synaglobal.vip}"
REPORT_DIR="${NEXUS_PROBE_REPORT_DIR:-/tmp/nexus-external-probe}"
mkdir -p "$REPORT_DIR"
OUT="$REPORT_DIR/probe-$(date -u +%Y%m%dT%H%M%SZ).txt"
log() { echo "$@" | tee -a "$OUT"; }
plog() { echo "$@" | tee -a "$OUT" >&2; }
pass() { log " [PASS] $*"; }
fail() { log " [FAIL] $*"; }
info() { log " [INFO] $*"; }
warn() { log " [WARN] $*"; }
# probe METHOD PATH [JSON_BODY] [extra curl args...]
# Prints HTTP status code to stdout only.
probe() {
local method="$1" path="$2" body="${3:-}"
shift 3 || true
local url="${BASE}${path}"
local tmp
tmp="$(mktemp)"
local -a curl_args=(-sS -o "$tmp" -w "%{http_code}" --max-time 20)
if [[ "$method" == "GET" ]]; then
:
else
curl_args+=(-X "$method" -H "Content-Type: application/json")
[[ -n "$body" ]] && curl_args+=(-d "$body")
fi
# shellcheck disable=SC2206
local code
code=$(curl "${curl_args[@]}" "$@" "$url" 2>>"$OUT" || echo "000")
local snippet
snippet="$(head -c 400 "$tmp" | tr '\n' ' ')"
plog ""
plog "── $method $path → HTTP $code"
plog " body: ${snippet:-<empty>}"
rm -f "$tmp"
echo "$code"
}
log "═══ Nexus external attack-surface probe ═══"
log "BASE=$BASE"
log "OUT=$OUT"
log "time=$(date -u -Iseconds)"
# ── Health ──
c=$(probe GET /health)
[[ "$c" == "200" ]] && pass "GET /health → 200" || fail "GET /health code=$c"
c=$(probe GET /health/detail)
[[ "$c" == "401" ]] && pass "GET /health/detail requires JWT" || fail "GET /health/detail code=$c (expected 401) — component leak if 200"
# ── OpenAPI / docs ──
c=$(probe GET /openapi.json)
[[ "$c" == "200" ]] && warn "GET /openapi.json accessible (schema disclosure)" || info "GET /openapi.json code=$c"
c=$(probe GET /docs)
[[ "$c" == "200" ]] && warn "GET /docs accessible" || info "GET /docs code=$c"
c=$(probe GET /redoc)
[[ "$c" == "200" ]] && warn "GET /redoc accessible" || info "GET /redoc code=$c"
# ── Install API ──
c=$(probe GET /api/install/status)
if [[ "$c" == "404" ]]; then
pass "GET /api/install/status hidden when locked (404)"
elif [[ "$c" == "200" ]]; then
warn "GET /api/install/status returns 200 (install state leak)"
else
info "GET /api/install/status code=$c"
fi
for ep in env-check connection-check state; do
c=$(probe GET "/api/install/$ep")
[[ "$c" == "404" ]] && pass "GET /api/install/$ep hidden when locked (404)" || fail "GET /api/install/$ep code=$c (expected 404)"
done
c=$(probe POST /api/install/init-db '{"db_host":"x"}')
[[ "$c" == "404" ]] && pass "POST /api/install/init-db hidden when locked (404)" || fail "POST /api/install/init-db code=$c (expected 404)"
c=$(probe POST /api/install/create-admin '{"username":"x","password":"x","install_token":"x"}')
[[ "$c" == "404" ]] && pass "POST /api/install/create-admin hidden when locked (404)" || fail "POST /api/install/create-admin code=$c (expected 404)"
c=$(probe POST /api/install/test-credentials '{}')
[[ "$c" == "404" ]] && pass "POST /api/install/test-credentials hidden when locked (404)" || fail "POST /api/install/test-credentials code=$c (expected 404)"
c=$(probe POST /api/install/lock '{}')
[[ "$c" == "404" ]] && pass "POST /api/install/lock hidden when locked (404)" || fail "POST /api/install/lock code=$c (expected 404)"
# ── Public settings ──
c=$(probe GET /api/settings/bing-wallpapers)
[[ "$c" == "200" ]] && pass "GET /api/settings/bing-wallpapers public read-only" || fail "code=$c"
c=$(probe POST /api/settings/bing-wallpapers/sync '{}')
[[ "$c" == "401" ]] && pass "POST bing-wallpapers/sync requires JWT" || fail "POST bing-wallpapers/sync code=$c (expected 401)"
# ── Protected admin API ──
c=$(probe GET /api/servers/)
[[ "$c" == "401" ]] && pass "GET /api/servers/ requires JWT" || fail "code=$c"
c=$(probe POST /api/servers/ '{"name":"x","domain":"1.2.3.4"}')
[[ "$c" == "401" ]] && pass "POST /api/servers/ requires JWT" || fail "code=$c"
c=$(probe GET /api/audit/)
[[ "$c" == "401" ]] && pass "GET /api/audit/ requires JWT" || fail "code=$c"
# ── Agent ──
c=$(probe POST /api/agent/heartbeat '{"server_id":1,"is_online":true}')
[[ "$c" == "401" || "$c" == "422" ]] && pass "POST /api/agent/heartbeat no key rejected ($c)" || fail "code=$c"
c=$(probe POST /api/agent/heartbeat '{"server_id":1,"is_online":true}' -H "X-API-Key: invalid-probe-key-000")
[[ "$c" == "401" || "$c" == "422" ]] && pass "POST /api/agent/heartbeat bad key rejected ($c)" || fail "code=$c"
c=$(probe POST /api/agent/script-callback \
'{"job_id":"probe","server_id":1,"secret":"x","exit_code":0}' \
-H "X-API-Key: invalid-probe-key-000")
[[ "$c" == "401" || "$c" == "404" || "$c" == "429" ]] && pass "POST script-callback bad key ($c)" || fail "code=$c"
# ── Auth login (single probe) ──
login_tmp="$(mktemp)"
curl -sS -o "$login_tmp" --max-time 15 -X POST \
-H "Content-Type: application/json" \
-d '{"username":"nonexistent_probe_user","password":"wrong"}' \
"${BASE}/api/auth/login" 2>>"$OUT" || true
login_body="$(cat "$login_tmp")"
rm -f "$login_tmp"
info "login wrong user: $login_body"
[[ "$login_body" == *"用户名或密码错误"* ]] && pass "login message unified (no user enumeration)" || warn "login message may leak enumeration"
# ── Static agent ──
c=$(probe GET /agent/install.sh)
[[ "$c" == "200" ]] && info "GET /agent/install.sh public (expected)" || info "code=$c"
# ── WebSocket ──
if command -v python3 >/dev/null 2>&1; then
log ""
log "── WebSocket probes"
python3 - "$BASE" 2>>"$OUT" <<'PY' | tee -a "$OUT" || true
import asyncio, sys
try:
import websockets
except ImportError:
print(" [SKIP] websockets not installed")
sys.exit(0)
base = sys.argv[1].rstrip("/")
ws_base = base.replace("https://", "wss://").replace("http://", "ws://")
async def probe_ws(path, token=None):
url = f"{ws_base}{path}"
if token is not None:
url += f"?token={token}"
try:
async with websockets.connect(url, open_timeout=10, close_timeout=5) as ws:
print(f" [FAIL] {path} connected without expected close")
except Exception as e:
msg = str(e)
if "4001" in msg or "Missing JWT" in msg:
print(f" [PASS] {path} no token → rejected ({msg[:80]})")
elif "4401" in msg or "Invalid" in msg or "expired" in msg.lower():
print(f" [PASS] {path} bad token → rejected ({msg[:80]})")
elif "403" in msg:
print(f" [INFO] {path} → reverse proxy 403 (app 4001/4401 not observable)")
else:
print(f" [INFO] {path} → {msg[:120]}")
async def main():
await probe_ws("/ws/alerts")
await probe_ws("/ws/alerts", "invalid.jwt.token")
await probe_ws("/ws/sync")
await probe_ws("/ws/terminal/1", "invalid.jwt.token")
asyncio.run(main())
PY
fi
log ""
log "═══ Probe complete ═══"
log "Full log: $OUT"
+49 -14
View File
@@ -34,25 +34,45 @@ logger = logging.getLogger("nexus.auth_jwt")
security = HTTPBearer(auto_error=False)
# Routes that bypass JWT auth (Agent heartbeat uses API Key, login obviously doesn't need JWT)
PUBLIC_PREFIXES = (
# Exact paths that bypass JWT (no prefix bleed — /health must not match /health/detail)
PUBLIC_EXACT_PATHS = frozenset({
"/api/auth/login",
"/api/auth/refresh",
"/api/auth/logout", # invalidates refresh token from body, no access JWT required
"/api/agent/", # Agent uses X-API-Key header
"/api/install/", # install wizard (403 when already installed, except /status)
"/api/settings/bing-wallpapers", # login page wallpaper slideshow
"/api/settings/bing-wallpaper", # single wallpaper (backward compat)
"/api/auth/logout",
"/api/settings/bing-wallpapers",
"/api/settings/bing-wallpaper",
"/health",
})
# Prefix paths — trailing slash required in definition to avoid accidental broad match
PUBLIC_PREFIXES = (
"/api/agent/",
"/api/install/",
"/ws/",
"/docs",
"/openapi.json",
"/redoc",
)
# OpenAPI/Swagger paths — only public when EXPOSE_API_DOCS is enabled
_API_DOC_PATHS = frozenset({"/docs", "/redoc", "/openapi.json"})
def _is_public_path(path: str) -> bool:
"""Check if the request path is public (doesn't require JWT)"""
def _api_docs_exposed() -> bool:
flag = (getattr(settings, "EXPOSE_API_DOCS", "false") or "false").lower()
return flag in ("true", "1", "yes", "on")
def _is_public_path(path: str, method: str = "GET") -> bool:
"""Check if the request path is public (doesn't require JWT).
Uses exact match for single-segment public endpoints; prefix match only for
agent/install/ws trees. Prevents /health/detail and bing-wallpapers/sync bleed.
"""
if path in PUBLIC_EXACT_PATHS:
if path in ("/api/settings/bing-wallpapers", "/api/settings/bing-wallpaper"):
if method.upper() not in ("GET", "HEAD", "OPTIONS"):
return False
return True
if _api_docs_exposed() and path in _API_DOC_PATHS:
return True
for prefix in PUBLIC_PREFIXES:
if path.startswith(prefix):
return True
@@ -111,7 +131,7 @@ class JwtAuthMiddleware:
await self.app(scope, receive, send)
return
if _is_public_path(path):
if _is_public_path(path, method):
await self.app(scope, receive, send)
return
@@ -157,7 +177,7 @@ async def get_current_admin(
For protected routes, raises 401 if no/invalid credentials.
"""
# Skip JWT for public routes — return None (route handler decides what to do)
if _is_public_path(request.url.path):
if _is_public_path(request.url.path, request.method):
if not credentials:
return None
@@ -183,6 +203,21 @@ async def get_current_admin(
return admin
async def require_current_admin(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
) -> Admin:
"""Like get_current_admin but never returns None — for handlers that must have JWT."""
admin = await get_current_admin(request, credentials)
if admin is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing Authorization header",
headers={"WWW-Authenticate": "Bearer"},
)
return admin
async def _verify_token(token: str, request: Request) -> Optional[Admin]:
"""Verify JWT access token → return Admin or None"""
try:
+2 -2
View File
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends
from fastapi.responses import PlainTextResponse
from sqlalchemy import text
from server.api.auth_jwt import get_current_admin
from server.api.auth_jwt import require_current_admin
from server.domain.models import Admin
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.redis.client import get_redis
@@ -33,7 +33,7 @@ async def health_check():
@router.get("/health/detail")
async def health_detail(admin: Admin = Depends(get_current_admin)):
async def health_detail(admin: Admin = Depends(require_current_admin)):
"""Detailed health diagnostics — requires admin JWT authentication.
Returns Redis/MySQL/WebSocket status for admin dashboard use.
+10 -10
View File
@@ -948,11 +948,16 @@ def _verify_install_token(provided: str) -> None:
# ── Endpoints ──
def _raise_not_found_if_locked() -> None:
"""Hide install API fingerprint after lock — same response as unknown route."""
if _is_locked():
raise HTTPException(status_code=404, detail="Not found")
@router.get("/status")
async def install_status():
"""Check whether Nexus is already installed."""
if _is_locked():
raise HTTPException(status_code=404, detail="Not found")
_raise_not_found_if_locked()
return {
"installed": _is_installed(),
"locked": _is_locked(),
@@ -961,12 +966,8 @@ async def install_status():
def _reject_if_locked():
"""Block install wizard mutations after step 5 lock."""
if _is_locked():
raise HTTPException(
status_code=403,
detail="安装向导已锁定。仅可查询 /api/install/status",
)
"""Block install wizard after step 5 lock (404 — no product fingerprint)."""
_raise_not_found_if_locked()
def _reject_if_env_exists():
@@ -1410,8 +1411,7 @@ async def lock_install():
This prevents unauthenticated callers from locking the installer
before the legitimate admin has finished setup.
"""
if _is_locked():
return {"success": True, "already_locked": True}
_raise_not_found_if_locked()
if not _has_env():
raise HTTPException(400, "请先完成步骤3:数据库初始化")
+2 -2
View File
@@ -19,7 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from server.api.dependencies import get_db
from server.api.auth_jwt import get_current_admin
from server.api.auth_jwt import get_current_admin, require_current_admin
from server.api.schemas import ScheduleCreate, ScheduleUpdate, PresetCreate, PresetUpdate, SettingUpdatePayload, ApiKeyRevealRequest, SshKeyPresetCreate, SshKeyPresetUpdate
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
from server.infrastructure.database.push_schedule_repo import PushScheduleRepositoryImpl, PushRetryJobRepositoryImpl
@@ -253,7 +253,7 @@ async def bing_wallpapers():
@router.post("/bing-wallpapers/sync", response_model=dict)
async def bing_wallpapers_sync(admin: Admin = Depends(get_current_admin)):
async def bing_wallpapers_sync(admin: Admin = Depends(require_current_admin)):
"""Admin-only: fetch latest Bing images into local cache."""
downloaded = await _sync_bing_wallpapers_from_network()
result = _cached_wallpaper_urls()
+3
View File
@@ -77,6 +77,9 @@ class Settings(BaseSettings):
TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_CHAT_ID: str = ""
# API docs — "true" exposes /docs /redoc /openapi.json (default off for production)
EXPOSE_API_DOCS: str = "false"
# Login IP allowlist master switch — "true" = enforce allowlist, "false" = allow all
LOGIN_ALLOWLIST_ENABLED: str = "false"
# Subscription URL for auto-refreshing subscription IPs (every 2h)
+12
View File
@@ -342,11 +342,23 @@ async def lifespan(app: FastAPI):
logger.info(f"{settings.SYSTEM_NAME} shutting down — background tasks cancelled")
def _expose_api_docs() -> bool:
flag = (getattr(settings, "EXPOSE_API_DOCS", "false") or "false").lower()
return flag in ("true", "1", "yes", "on")
_docs_url = "/docs" if _expose_api_docs() else None
_redoc_url = "/redoc" if _expose_api_docs() else None
_openapi_url = "/openapi.json" if _expose_api_docs() else None
app = FastAPI(
title=settings.SYSTEM_NAME if not is_install_mode() else "Nexus Installer",
description=f"{settings.SYSTEM_TITLE}" if not is_install_mode() else "Nexus Installation Wizard",
version="6.0.0",
lifespan=lifespan,
docs_url=_docs_url,
redoc_url=_redoc_url,
openapi_url=_openapi_url,
)
@@ -0,0 +1,33 @@
"""Regression tests for external attack-surface hardening (BL-01BL-04)."""
import pytest
class TestPublicPathMatching:
def test_health_detail_not_public(self):
from server.api.auth_jwt import _is_public_path
assert _is_public_path("/health") is True
assert _is_public_path("/health/detail") is False
def test_bing_wallpapers_get_public_post_sync_protected(self):
from server.api.auth_jwt import _is_public_path
assert _is_public_path("/api/settings/bing-wallpapers", "GET") is True
assert _is_public_path("/api/settings/bing-wallpaper", "GET") is True
assert _is_public_path("/api/settings/bing-wallpapers/sync", "POST") is False
def test_openapi_docs_off_by_default(self):
from server.api.auth_jwt import _is_public_path
assert _is_public_path("/openapi.json") is False
assert _is_public_path("/docs") is False
assert _is_public_path("/redoc") is False
def test_agent_install_ws_prefixes_still_public(self):
from server.api.auth_jwt import _is_public_path
assert _is_public_path("/api/agent/heartbeat") is True
assert _is_public_path("/api/install/status") is True
assert _is_public_path("/ws/alerts") is True
+103
View File
@@ -0,0 +1,103 @@
"""BL-05: locked install API returns 404 (no fingerprint) on all endpoints."""
from unittest.mock import patch
import pytest
from fastapi import HTTPException
@pytest.fixture
def locked():
with patch("server.api.install._is_locked", return_value=True):
yield
@pytest.fixture
def unlocked():
with patch("server.api.install._is_locked", return_value=False):
yield
def _assert_not_found(exc: HTTPException) -> None:
assert exc.status_code == 404
assert exc.detail == "Not found"
@pytest.mark.asyncio
async def test_status_locked_404(locked):
from server.api.install import install_status
with pytest.raises(HTTPException) as exc:
await install_status()
_assert_not_found(exc.value)
@pytest.mark.asyncio
async def test_status_unlocked_200(unlocked):
from server.api.install import install_status
with patch("server.api.install._has_env", return_value=False):
result = await install_status()
assert result["locked"] is False
assert "configured" in result
@pytest.mark.asyncio
async def test_env_check_locked_404(locked):
from server.api.install import env_check
with pytest.raises(HTTPException) as exc:
await env_check()
_assert_not_found(exc.value)
@pytest.mark.asyncio
async def test_connection_check_locked_404(locked):
from server.api.install import connection_check
with pytest.raises(HTTPException) as exc:
await connection_check()
_assert_not_found(exc.value)
@pytest.mark.asyncio
async def test_state_locked_404(locked):
from server.api.install import get_install_state
with pytest.raises(HTTPException) as exc:
await get_install_state()
_assert_not_found(exc.value)
@pytest.mark.asyncio
async def test_lock_endpoint_locked_404(locked):
from server.api.install import lock_install
with pytest.raises(HTTPException) as exc:
await lock_install()
_assert_not_found(exc.value)
@pytest.mark.asyncio
async def test_init_db_locked_404(locked):
from server.api.install import init_db, InitDbRequest
req = InitDbRequest(
db_host="localhost",
db_port="3306",
db_name="nexus",
db_user="root",
db_pass="x",
)
with pytest.raises(HTTPException) as exc:
await init_db(req)
_assert_not_found(exc.value)
def test_reject_if_locked_raises_404():
from server.api.install import _reject_if_locked
with patch("server.api.install._is_locked", return_value=True):
with pytest.raises(HTTPException) as exc:
_reject_if_locked()
_assert_not_found(exc.value)
+3 -1
View File
@@ -63,7 +63,9 @@ class TestJWTAuth:
assert _is_public_path("/api/agent/heartbeat") is True
assert _is_public_path("/health") is True
assert _is_public_path("/ws/alerts") is True
assert _is_public_path("/docs") is True
assert _is_public_path("/api/settings/bing-wallpapers", "GET") is True
assert _is_public_path("/docs") is False
assert _is_public_path("/health/detail") is False
def test_protected_path_detection(self):
from server.api.auth_jwt import _is_public_path
+2
View File
@@ -147,6 +147,8 @@ def test_public_path_install_and_logout():
assert _is_public_path("/api/install/status") is True
assert _is_public_path("/api/auth/logout") is True
assert _is_public_path("/health/detail") is False
assert _is_public_path("/api/settings/bing-wallpapers/sync", "POST") is False
def _cdn_offenders_in_text(path: Path, text: str) -> bool:
+4
View File
@@ -126,6 +126,7 @@ class TestJWTAuth:
assert _is_public_path("/api/agent/heartbeat") is True
assert _is_public_path("/health") is True
assert _is_public_path("/ws/alerts") is True
assert _is_public_path("/api/settings/bing-wallpapers", "GET") is True
def test_protected_path_detection(self):
"""Protected paths are correctly identified"""
@@ -137,6 +138,9 @@ class TestJWTAuth:
assert _is_public_path("/api/assets/platforms") is False
assert _is_public_path("/api/auth/me") is False
assert _is_public_path("/api/auth/totp/setup") is False
assert _is_public_path("/health/detail") is False
assert _is_public_path("/api/settings/bing-wallpapers/sync", "POST") is False
assert _is_public_path("/openapi.json") is False
class TestAsyncSSHPool: