Files
danyang-match/docs/渗透测试报告_v1.0.md
T

390 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🔴 丹阳公益婚恋服务平台 — 渗透测试报告
> **项目:** 丹阳公益婚恋服务平台
> **测试日期:** 2026-09-02
> **测试范围:** 后端 API、认证机制、权限控制、WebSocket、配置文件
> **测试方法:** 代码静态分析 + 架构审查(未进行实际攻击)
---
## 📋 项目技术架构
| 层面 | 技术栈 |
|------|--------|
| 后端 | Java 21 + Spring Boot 3.2.5 + PostgreSQL + Redis |
| 小程序 | 微信小程序原生 + TypeScript |
| 管理后台 | Vue 3 + Vite + Pinia + Element Plus |
| 网关 | Nginx(速率限制) |
| 部署 | Docker + Docker Compose |
---
## 🚨 漏洞总览
| 等级 | 编号 | 漏洞名称 | 影响 |
|------|------|---------|------|
| 🔴 严重 | C-01 | Operator 接口 IDOR | 可冒充任意 Operator 操作 |
| 🔴 严重 | C-02 | Operator 权限 Filter 完全旁路 | 所有 Operator 接口无权限校验 |
| 🔴 严重 | C-03 | WebSocket 未验证 Token | 可假冒任意用户接收消息 |
| 🔴 严重 | C-04 | JWT 密钥硬编码 + 可预测 | 可伪造任意用户/Operator Token |
| 🟠 高危 | H-01 | 任意用户公开资料 IDOR | 无需登录即可枚举所有用户资料 |
| 🟠 高危 | H-02 | CORS 配置错误 | 允许任意来源跨域 |
| 🟠 高危 | H-03 | 核心写操作无幂等 Key | 网络重放可产生重复记录 |
| 🟠 高危 | H-04 | Refresh Token 泄露后无法完全撤销 | 14天有效期内可被滥用 |
| 🟠 高危 | H-05 | 操作审计日志无有效身份 | 安全事件无法溯源 |
| 🟡 中危 | M-01 | JWT Access Token 无 Blacklist | 登出后 Token 在过期前仍有效 |
| 🟡 中危 | M-02 | 文件上传安全检查未实现 | Phase 10 TODO 遗留 |
| 🟡 中危 | M-03 | 缺少安全响应头 | 浏览器安全机制未启用 |
| 🟡 中危 | M-04 | AES 密钥长度不足 | 16 bytes 而非 32 bytes |
| 🟡 中危 | M-05 | Operator 账号可枚举 | 便于钓鱼攻击 |
| 🟡 中危 | M-06 | 无登录失败锁定 | 暴力猜解无防护 |
| 🟢 低危 | L-01 | Actuator 端点未限权 | 指标信息暴露 |
| 🟢 低危 | L-02 | 日志脱敏不完整 | 敏感信息可能写入日志 |
| 🟢 低危 | L-03 | Redis 无密码认证 | Redis 服务可被内网访问 |
---
## 🔴 严重漏洞(Critical)
---
### 【C-01】Operator 接口 IDOR — 可冒充任意 Operator
**文件路径:** `backend/src/main/java/com/danyang/match/platform/activity/controller/CheckinController.java`
**漏洞行号:** 第 107 行
**漏洞代码:**
```java
private String extractOperatorId(String authHeader) {
String token = extractToken(authHeader); // 提取 Bearer token 字符串
return token; // ❌ 直接返回原始 token,而非解析出的 operatorId
}
```
**对比正确实现(`AuthController.java`):**
```java
// ✅ 正确:从 JWT 中解析 userId
String userId = jwtTokenService.getUserIdFromToken(token);
```
**根因:** `extractOperatorId()` 返回的是 `Bearer eyJhbG...` 中的原始 token 字符串,而非通过 `jwtTokenService.getUserIdFromToken(token)` 解析出的 operatorId。后续调用方收到的是无意义的字符串,但代码并未真正使用该值做查询,导致**路径参数中的 Operator ID 不受任何校验**。
**影响范围:** 所有使用 `extractOperatorId()` 的 Operator 端接口:
| 受影响文件 | 受影响接口 | 后果 |
|-----------|-----------|------|
| `AccountOperatorController.java` | `POST /operator/account/{userId}/close` | 可强制注销**任意用户**账号 |
| `AccountOperatorController.java` | `GET /operator/account/{userId}/can-withdraw` | 可查询**任意用户**注销资格 |
| `OperatorController.java` | `PUT /operator/{id}/disable` | 可禁用/启用**任意 Operator** |
| `OperatorController.java` | `PUT /operator/{id}/enable` | 同上 |
| `OperatorController.java` | `PUT /operator/{id}/reset-password` | 可重置**任意 Operator**密码 |
| `CheckinController.java` | `POST /operator/{activityId}/checkin/manual` | 可代替**任何人**手工签到 |
| `CheckinController.java` | `GET /operator/{activityId}/checkin-stats` | 可查看任意活动签到统计 |
**修复方案:**
```java
private String extractOperatorId(String authHeader) {
String token = extractToken(authHeader);
return jwtTokenService.getUserIdFromToken(token); // 解析 token 获取真实 operatorId
}
```
---
### 【C-02】Operator 权限 Filter 完全旁路
**文件路径:** `backend/src/main/java/com/danyang/match/platform/shared/filter/PermissionFilter.java`
**漏洞行号:** 第 57 行
**漏洞代码:**
```java
// TODO: Phase 02 后续完善权限校验逻辑
// 目前简化:只要通过 JWT 认证即允许访问 Operator 接口
chain.doFilter(request, response); // 无条件放行
```
**根因:** `PermissionFilter` 中权限校验逻辑为 TODO 状态,注释明确说明"只要通过 JWT 认证即允许访问",完全跳过了权限码与 Operator 权限列表的比对。
**影响:** 任何持有 Operator JWT 的攻击者,可访问**全部 Operator 接口**,包括创建/删除 Operator 账号、修改任意用户账号状态、查看所有审核任务和举报记录、访问敏感运营数据。
**修复方案:** 实现完整权限校验逻辑,从请求路径解析所需权限码,与数据库中 Operator 的权限列表比对。参考 Phase 02 规范中的 `permission.manage`、`message.content.review` 等权限定义。
---
### 【C-03】WebSocket 连接未验证 Token
**文件路径:** `backend/src/main/java/com/danyang/match/platform/messaging/websocket/ChatWebSocketHandler.java`
**漏洞行号:** `afterConnectionEstablished` 方法
**漏洞代码:**
```java
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String queryString = session.getUri().getQuery();
String userId = null;
String token = null;
if (queryString != null) {
for (String param : queryString.split("&")) {
String[] parts = param.split("=");
if (parts.length == 2) {
if ("userId".equals(parts[0])) userId = parts[1];
if ("token".equals(parts[0])) token = parts[1];
}
}
}
if (userId == null || token == null) {
session.close(CloseStatus.NOT_ACCEPTABLE);
return;
}
// TODO: Phase 09 验证Token有效性 ← Token 未被验证
sessionUserMap.put(session.getId(), userId); // 直接信任 userId
}
```
**根因:** 从 URL query string 获取 `userId` 和 `token` 后,`token` 未传入 `jwtTokenService.validateToken()` 验证,`userId` 直接从客户端可控参数写入 session 映射。
**影响:**
1. 攻击者可假冒任意用户建立 WebSocket 连接
2. 接收该用户的所有实时消息推送(聊天内容)
3. 冒充该用户发送 WebSocket 消息
**攻击示例:**
```
wss://target.com/ws/chat?userId=VICTIM_UUID&token=fake_or_any_token
```
**修复方案:**
```java
// 在 afterConnectionEstablished 中验证 token
if (!jwtTokenService.validateToken(token)) {
session.close(CloseStatus.NOT_ACCEPTABLE);
return;
}
String verifiedUserId = jwtTokenService.getUserIdFromToken(token);
if (!verifiedUserId.equals(userId)) {
session.close(CloseStatus.NOT_ACCEPTABLE);
return;
}
```
---
### 【C-04】JWT Access Token 密钥硬编码 + 可预测
**文件路径:** `backend/src/main/resources/application.yml`
**漏洞配置:**
```yaml
jwt:
access-token-secret: ${JWT_ACCESS_SECRET:changeme-access-secret-change-in-production}
refresh-token-secret: ${JWT_REFRESH_SECRET:changeme-refresh-secret-change-in-production}
```
**`infrastructure/docker-compose.yml`(dev 环境):**
```yaml
JWT_ACCESS_SECRET: dev-access-secret-do-not-use-in-production
JWT_REFRESH_SECRET: dev-refresh-secret-do-not-use-in-production
```
**根因:**
1. dev 和 staging 环境的 JWT 密钥是公开可预测的字符串
2. `application.yml` 中默认值 `changeme-...` 如果生产环境 `.env` 未覆盖则直接暴露
3. 攻击者获取任意一个 JWT 后,可基于已知密钥伪造任意用户或 Operator 的 token
**修复方案:**
生成符合 JJWT 要求的密钥(HS512 需要至少 512 bits = 64 bytes):
```bash
# 生成 80 字节 base64url 编码的强密钥(107 字符)
python3 -c "import secrets, base64; print(base64.urlsafe_b64encode(secrets.token_bytes(80)).rstrip(b'=').decode())"
```
生产环境 `.env` 必须配置:
```bash
JWT_ACCESS_SECRET=<生成的强密钥,107字符base64url>
JWT_REFRESH_SECRET=<另一个独立强密钥>
```
---
## 🟠 高危漏洞(High)
---
### 【H-01】任意用户公开资料 IDOR — 无需登录可枚举
**文件路径:** `backend/src/main/java/com/danyang/match/platform/profile/controller/ProfileController.java`
**漏洞行号:** 第 60 行
**漏洞代码:**
```java
@GetMapping("/{userId}")
public ApiResponse<PublicUserProfileDTO> getPublicProfile(@PathVariable String userId) {
// ❌ 没有任何 Authorization header 校验
PublicUserProfileDTO profile = userProfileService.getPublicProfile(userId);
if (profile == null) {
return ApiResponse.error(..., "用户不存在");
}
return ApiResponse.ok(profile);
}
```
**根因:** `ProfileController.getPublicProfile` 未校验 Authorization header,未登录用户可直接调用 `/api/v1/profile/{userId}` 获取他人公开资料。
**修复方案:**
```java
@GetMapping("/{userId}")
public ApiResponse<PublicUserProfileDTO> getPublicProfile(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String userId) {
if (authHeader == null) {
return ApiResponse.error(ApiResponseCode.UNAUTHORIZED, "请先登录");
}
String currentUserId = extractUserId(authHeader);
PublicUserProfileDTO profile = userProfileService.getPublicProfile(currentUserId, userId);
// 在 UserProfileService 中检查 currentUserId 是否有权查看 targetUserId 的资料
}
```
---
### 【H-02】CORS 配置允许任意来源
**文件路径:** `backend/src/main/java/com/danyang/match/platform/shared/config/CorsConfig.java`
**漏洞代码:**
```java
config.addAllowedOriginPattern("*"); // 允许所有来源
config.setAllowCredentials(true);
```
**修复方案:** 限制为已知域名,或从环境变量读取:
```java
config.addAllowedOriginPattern(System.getenv("CORS_ALLOWED_ORIGINS"));
```
---
### 【H-03】核心写操作无幂等 Key
涉及接口:`POST /api/v1/relations/{targetUserId}/like`、`POST /api/v1/relations/{targetUserId}/block`、`POST /api/v1/messages/conversations/{id}/send`。
在核心写操作接口中读取 `Idempotency-Key` header,使用 Redis 存储(key = `idempotency:{userId}:{idempotencyKey}`,TTL = 24h)实现防重放。
---
### 【H-04】Refresh Token 泄露后无法完全撤销
**文件路径:** `backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java`
Redis 中存储的 refreshToken 只用于"是否颁发过",但 JWT 本身即使被删除也能在剩余有效期内(14天)被使用。
**修复方案:** 在 Redis blacklist 中标记已使用的 refreshToken jti,或在 accessToken 中加入 `jti`(JWT ID)字段并在 Redis 中维护 blacklist。
---
### 【H-05】操作审计日志无有效操作者身份
**文件路径:** `CheckinService.java`、`OperatorAuthService.java`
由于 C-01 漏洞,`staffOperatorId` 传入的是无意义的字符串,审计日志无法用于事件溯源。修复 C-01 后自然解决。
---
## 🟡 中危漏洞(Medium)
### 【M-01】JWT Access Token 无 Blacklist
`validateToken()` 只验证签名和时间戳,不检查 Redis blacklist。登出后旧 token 在过期前(2小时)仍可使用。
### 【M-02】文件上传安全检查未实现
`PhotoController.java` 和 `ChatController.java` 中 Phase 10 TODO,`upload-token` 返回 placeholder URL,MinIO 存储配置暴露在客户端。
### 【M-03】缺少安全响应头
`nginx.conf` 缺少 `X-Content-Type-Options`、`X-Frame-Options`、`Content-Security-Policy`、`Strict-Transport-Security`、`Referrer-Policy`。
### 【M-04】AES 加密密钥长度不足
`infrastructure/docker-compose.yml` 中 `ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef` 只有 16 bytes,AES-256 需要 32 bytes。
### 【M-05】无登录失败账户锁定
`OperatorAuthService.java` 中暴力猜解密码无防护,建议 5 次失败后锁定账户。
### 【M-06】Redis 无密码认证
`docker-compose.yml` 中 Redis 未配置密码。
---
## ✅ 设计亮点(未被攻破)
| 方面 | 状态 |
|------|------|
| 密码加密(BCrypt) | ✅ |
| 敏感字段 AES-256-GCM + 随机 IV | ✅ |
| 身份证加密 + Hash 查重 | ✅ |
| 聊天 Mutual Match 校验 | ✅ |
| Block 优先于所有关系操作 | ✅ |
| 数据库唯一约束防重复 | ✅ |
| 签到 Token HMAC + Redis 防重放 | ✅ |
| SQL 注入防护(MyBatis-Plus) | ✅ |
| 统一错误响应格式 | ✅ |
---
## 🔧 修复优先级
### P0 — 必须立即修复(上线前)
| 编号 | 修复内容 | 涉及文件 |
|------|---------|---------|
| C-01 | 修复 `extractOperatorId()` 返回真实 operatorId | `CheckinController.java`、`AccountOperatorController.java`、`OperatorController.java` |
| C-02 | 实现 `PermissionFilter` 权限校验逻辑 | `PermissionFilter.java` |
| C-03 | WebSocket 连接时验证 Token | `ChatWebSocketHandler.java` |
| C-04 | 生产环境配置随机强 JWT 密钥 | `.env`、`docker-compose.prod.yml` |
### P1 — 上线前修复
| 编号 | 修复内容 | 涉及文件 |
|------|---------|---------|
| H-01 | `getPublicProfile` 加登录校验和关系校验 | `ProfileController.java`、`UserProfileService.java` |
| H-02 | CORS 配置限制为已知域名 | `CorsConfig.java` |
| H-04 | refreshToken 加入 Redis blacklist | `AuthService.java`、`JwtTokenService.java` |
| M-01 | accessToken 加入 Redis blacklist | `JwtTokenService.java` |
| M-04 | 修正 ENCRYPTION_KEY 为 32 bytes | `.env`、各环境 compose |
| M-05 | 登录失败锁定(5次) | `OperatorAuthService.java` |
### P2 — 上线后迭代
| 编号 | 修复内容 |
|------|---------|
| H-03 | 关键写操作添加 Idempotency-Key |
| M-02 | 文件上传安全检查(Content-Type、Magic Number、EXIF) |
| M-03 | Nginx 添加安全响应头 |
| M-06 | Redis 密码配置 |
| L-01 | Actuator 端点 IP 白名单 |
| L-02 | 日志脱敏审计 |
---
## 📁 漏洞定位索引
| 文件路径 | 漏洞 |
|---------|------|
| `activity/controller/CheckinController.java:107` | C-01(extractOperatorId 返回 token 字符串) |
| `account/controller/AccountOperatorController.java` | C-01(受影响) |
| `operator/controller/OperatorController.java` | C-01(受影响) |
| `shared/filter/PermissionFilter.java:57` | C-02(TODO 未实现) |
| `messaging/websocket/ChatWebSocketHandler.java:51` | C-03(TODO 未验证 token) |
| `profile/controller/ProfileController.java:60` | H-01(无需认证) |
| `shared/config/CorsConfig.java:16` | H-02(`*` 通配符) |
| `auth/service/AuthService.java:64` | H-04(refreshToken 可在 Redis 删除后使用) |
| `auth/token/JwtTokenService.java` | M-01(无 blacklist) |
| `photo/controller/PhotoController.java` | M-02(TODO) |
| `infrastructure/nginx.conf` | M-03(缺安全头) |
| `infrastructure/docker-compose.yml` | C-04, M-04, M-06 |