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:
@@ -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-01~BL-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 401;bing sync 401;openapi 404
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
# Changelog — Install 锁定后全端点 404(BL-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-06~08 |
|
||||
| `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 @@
|
||||
# 外网攻击面加固 Backlog(audit_only 产出)
|
||||
|
||||
**日期**:2026-06-06
|
||||
**来源**:[`2026-06-06-external-attack-surface-audit.md`](2026-06-06-external-attack-surface-audit.md)
|
||||
**状态**:BL-01~BL-05 **已实施**;BL-06~BL-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 401;bing sync 401;openapi 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/P1:0**(无未授权 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;**非**控制面 RCE(exec 在子机 `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-05(2026-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 路径 schema(EXT-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**:2(EXT-01、EXT-02)
|
||||
- **H**:2+(EXT-03、EXT-04、OpenAPI 指纹、Agent legacy 已接受)
|
||||
|
||||
### 与 B1–B6 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-01~BL-05(见 backlog 与 changelog)。
|
||||
|
||||
---
|
||||
|
||||
**下一步**:部署 BL-05 后重跑 `security_probe_external.sh`;BL-06~08 按运维需要再排期。
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:数据库初始化")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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-01–BL-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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user