15 KiB
🔴 丹阳公益婚恋服务平台 — 渗透测试报告
项目: 丹阳公益婚恋服务平台
测试日期: 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 行
漏洞代码:
private String extractOperatorId(String authHeader) {
String token = extractToken(authHeader); // 提取 Bearer token 字符串
return token; // ❌ 直接返回原始 token,而非解析出的 operatorId
}
对比正确实现(AuthController.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 |
可查看任意活动签到统计 |
修复方案:
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 行
漏洞代码:
// 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 方法
漏洞代码:
@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 映射。
影响:
- 攻击者可假冒任意用户建立 WebSocket 连接
- 接收该用户的所有实时消息推送(聊天内容)
- 冒充该用户发送 WebSocket 消息
攻击示例:
wss://target.com/ws/chat?userId=VICTIM_UUID&token=fake_or_any_token
修复方案:
// 在 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
漏洞配置:
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 环境):
JWT_ACCESS_SECRET: dev-access-secret-do-not-use-in-production
JWT_REFRESH_SECRET: dev-refresh-secret-do-not-use-in-production
根因:
- dev 和 staging 环境的 JWT 密钥是公开可预测的字符串
application.yml中默认值changeme-...如果生产环境.env未覆盖则直接暴露- 攻击者获取任意一个 JWT 后,可基于已知密钥伪造任意用户或 Operator 的 token
修复方案:
生成符合 JJWT 要求的密钥(HS512 需要至少 512 bits = 64 bytes):
# 生成 80 字节 base64url 编码的强密钥(107 字符)
python3 -c "import secrets, base64; print(base64.urlsafe_b64encode(secrets.token_bytes(80)).rstrip(b'=').decode())"
生产环境 .env 必须配置:
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 行
漏洞代码:
@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} 获取他人公开资料。
修复方案:
@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
漏洞代码:
config.addAllowedOriginPattern("*"); // 允许所有来源
config.setAllowCredentials(true);
修复方案: 限制为已知域名,或从环境变量读取:
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 |