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

15 KiB
Raw Blame History

🔴 丹阳公益婚恋服务平台 — 渗透测试报告

项目: 丹阳公益婚恋服务平台
测试日期: 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 映射。

影响:

  1. 攻击者可假冒任意用户建立 WebSocket 连接
  2. 接收该用户的所有实时消息推送(聊天内容)
  3. 冒充该用户发送 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

根因:

  1. dev 和 staging 环境的 JWT 密钥是公开可预测的字符串
  2. application.yml 中默认值 changeme-... 如果生产环境 .env 未覆盖则直接暴露
  3. 攻击者获取任意一个 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