303 lines
9.6 KiB
Markdown
303 lines
9.6 KiB
Markdown
# P0 漏洞修复 — 给 Zcode 的执行指令
|
||||
|
|
|
|||
|
|
> 来源:渗透测试报告 v1.0(2026-09-02)
|
|||
|
|
> 目标:上线前必须完成的 4 个严重漏洞修复
|
|||
|
|
> 注意:以下所有修改只涉及指定文件的指定行,请勿改动其他代码
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 【C-01】修复 extractOperatorId() — 返回真实 operatorId
|
|||
|
|
|
|||
|
|
### 问题
|
|||
|
|
`CheckinController.java`、`AccountOperatorController.java`、`OperatorController.java` 中,`extractOperatorId()` 直接返回原始 token 字符串而非解析出的 operatorId,导致路径参数中的目标 Operator ID 不受任何校验,攻击者可冒充任意 Operator。
|
|||
|
|
|
|||
|
|
### 修复方案
|
|||
|
|
所有使用 `extractOperatorId()` 的地方,统一改为调用 `jwtTokenService.getUserIdFromToken(token)` 解析真实 ID。
|
|||
|
|
|
|||
|
|
### 具体修改
|
|||
|
|
|
|||
|
|
#### 文件 1:`activity/controller/CheckinController.java`
|
|||
|
|
|
|||
|
|
**当前代码(第 104-110 行):**
|
|||
|
|
```java
|
|||
|
|
private String extractOperatorId(String authHeader) {
|
|||
|
|
String token = extractToken(authHeader);
|
|||
|
|
return token;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**改为:**
|
|||
|
|
```java
|
|||
|
|
private String extractOperatorId(String authHeader) {
|
|||
|
|
String token = extractToken(authHeader);
|
|||
|
|
return jwtTokenService.getUserIdFromToken(token);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
#### 文件 2:`account/controller/AccountOperatorController.java`
|
|||
|
|
|
|||
|
|
**当前代码(第 62-67 行):**
|
|||
|
|
```java
|
|||
|
|
private String extractOperatorId(String authHeader) {
|
|||
|
|
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
|||
|
|
throw new RuntimeException("未认证");
|
|||
|
|
}
|
|||
|
|
return authHeader.substring(7);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**改为:**
|
|||
|
|
```java
|
|||
|
|
private String extractOperatorId(String authHeader) {
|
|||
|
|
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
|||
|
|
throw new RuntimeException("未认证");
|
|||
|
|
}
|
|||
|
|
String token = authHeader.substring(7);
|
|||
|
|
return jwtTokenService.getUserIdFromToken(token);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> ⚠️ 注意:该文件需要注入 `JwtTokenService`,检查顶部是否有:
|
|||
|
|
> ```java
|
|||
|
|
> private final JwtTokenService jwtTokenService;
|
|||
|
|
> ```
|
|||
|
|
> 如果没有,添加字段注入。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
#### 文件 3:`operator/controller/OperatorController.java`
|
|||
|
|
|
|||
|
|
**当前代码(第 78-83 行):**
|
|||
|
|
```java
|
|||
|
|
private String extractId(String authHeader) {
|
|||
|
|
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
|||
|
|
throw new RuntimeException("未认证");
|
|||
|
|
}
|
|||
|
|
return authHeader.substring(7);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**改为:**
|
|||
|
|
```java
|
|||
|
|
private String extractId(String authHeader) {
|
|||
|
|
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
|||
|
|
throw new RuntimeException("未认证");
|
|||
|
|
}
|
|||
|
|
String token = authHeader.substring(7);
|
|||
|
|
return jwtTokenService.getUserIdFromToken(token);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> ⚠️ 注意:该文件需要注入 `JwtTokenService`,检查顶部是否有:
|
|||
|
|
> ```java
|
|||
|
|
> private final JwtTokenService jwtTokenService;
|
|||
|
|
> ```
|
|||
|
|
> 如果没有,添加字段注入。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 【C-02】实现 PermissionFilter 权限校验逻辑
|
|||
|
|
|
|||
|
|
### 问题
|
|||
|
|
`PermissionFilter.java` 第 57 行:`chain.doFilter(request, response);` 无条件放行,所有 Operator 接口无需检查实际权限,只要有 JWT 即可访问。
|
|||
|
|
|
|||
|
|
### 修复方案
|
|||
|
|
读取请求路径所需的权限码,与数据库中 Operator 的权限列表比对。
|
|||
|
|
|
|||
|
|
### 具体修改
|
|||
|
|
|
|||
|
|
#### 文件:`shared/filter/PermissionFilter.java`
|
|||
|
|
|
|||
|
|
**当前代码(`doFilterInternal` 方法末尾):**
|
|||
|
|
```java
|
|||
|
|
// TODO: Phase 02 后续完善权限校验逻辑
|
|||
|
|
// 目前简化:只要通过 JWT 认证即允许访问 Operator 接口
|
|||
|
|
chain.doFilter(request, response);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**改为:**
|
|||
|
|
```java
|
|||
|
|
// 从 PATH_PERMISSION_MAP 获取路径对应的必需权限码
|
|||
|
|
String requiredPermission = null;
|
|||
|
|
for (Map.Entry<String, String> entry : PATH_PERMISSION_MAP.entrySet()) {
|
|||
|
|
if (pathMatcher.match(entry.getKey(), request.getRequestURI())) {
|
|||
|
|
requiredPermission = entry.getValue();
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (requiredPermission == null) {
|
|||
|
|
chain.doFilter(request, response);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 从 request attribute 获取 operatorId(由 JwtAuthFilter 设置)
|
|||
|
|
String operatorId = (String) request.getAttribute("currentOperatorId");
|
|||
|
|
if (operatorId == null) {
|
|||
|
|
chain.doFilter(request, response);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取 Operator 的权限列表
|
|||
|
|
List<String> permissions = permissionService.getPermissionsByOperatorId(operatorId);
|
|||
|
|
if (permissions == null || !permissions.contains(requiredPermission)) {
|
|||
|
|
response.setContentType("application/json;charset=UTF-8");
|
|||
|
|
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
|||
|
|
response.getWriter().write("{"success":false,"code":"FORBIDDEN","message":"权限不足","data":null}");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
chain.doFilter(request, response);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**同时在文件顶部 import 添加(如没有):**
|
|||
|
|
```java
|
|||
|
|
import jakarta.servlet.http.HttpServletResponse;
|
|||
|
|
import java.util.List;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 【C-03】WebSocket 连接时验证 Token
|
|||
|
|
|
|||
|
|
### 问题
|
|||
|
|
`ChatWebSocketHandler.java` 的 `afterConnectionEstablished` 方法中,从 URL query string 获取 `userId` 和 `token` 后,`token` 未被验证。
|
|||
|
|
|
|||
|
|
### 具体修改
|
|||
|
|
|
|||
|
|
#### 文件:`messaging/websocket/ChatWebSocketHandler.java`
|
|||
|
|
|
|||
|
|
**找到 `afterConnectionEstablished` 方法(约第 30-55 行),将整个方法替换为:**
|
|||
|
|
|
|||
|
|
```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 = URLDecoder.decode(parts[1], StandardCharsets.UTF_8);
|
|||
|
|
if ("token".equals(parts[0])) token = URLDecoder.decode(parts[1], StandardCharsets.UTF_8);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (userId == null || token == null) {
|
|||
|
|
session.close(CloseStatus.NOT_ACCEPTABLE);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证 Token 有效性
|
|||
|
|
if (!messagingService.validateToken(token)) {
|
|||
|
|
log.warn("WebSocket Token 验证失败: userId={}", userId);
|
|||
|
|
session.close(CloseStatus.NOT_ACCEPTABLE);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证 userId 与 token 中解析出的 userId 一致
|
|||
|
|
String verifiedUserId = messagingService.getUserIdFromToken(token);
|
|||
|
|
if (!verifiedUserId.equals(userId)) {
|
|||
|
|
log.warn("WebSocket userId 不匹配: urlUserId={}, tokenUserId={}", userId, verifiedUserId);
|
|||
|
|
session.close(CloseStatus.NOT_ACCEPTABLE);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sessionUserMap.put(session.getId(), userId);
|
|||
|
|
userSessionMap.put(userId, session.getId());
|
|||
|
|
|
|||
|
|
log.info("WebSocket连接建立: userId={}, sessionId={}", userId, session.getId());
|
|||
|
|
|
|||
|
|
sendToUser(userId, Map.of("type", "connected", "userId", userId));
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> ⚠️ 注意:`MessagingService` 需要新增两个方法:
|
|||
|
|
> - `validateToken(String token)` → 调用 `jwtTokenService.validateToken(token)`
|
|||
|
|
> - `getUserIdFromToken(String token)` → 调用 `jwtTokenService.getUserIdFromToken(token)`
|
|||
|
|
>
|
|||
|
|
> 或者直接在 `ChatWebSocketHandler` 中注入 `JwtTokenService`。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
#### 在 `ChatWebSocketHandler.java` 中注入 JwtTokenService
|
|||
|
|
|
|||
|
|
**在类字段区添加(如没有):**
|
|||
|
|
```java
|
|||
|
|
private final JwtTokenService jwtTokenService;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**在构造方法参数中添加:**
|
|||
|
|
```java
|
|||
|
|
public ChatWebSocketHandler(MessagingService messagingService, JwtTokenService jwtTokenService) {
|
|||
|
|
this.messagingService = messagingService;
|
|||
|
|
this.jwtTokenService = jwtTokenService;
|
|||
|
|
this.objectMapper = new ObjectMapper();
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**在 `afterConnectionEstablished` 方法顶部添加 import:**
|
|||
|
|
```java
|
|||
|
|
import java.net.URLDecoder;
|
|||
|
|
import java.nio.charset.StandardCharsets;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 【C-04】生产环境 JWT 密钥配置(环境/运维任务,非代码修改)
|
|||
|
|
|
|||
|
|
### 问题
|
|||
|
|
dev/staging 环境的 JWT 密钥是公开可预测的字符串。如果生产 `.env` 未覆盖默认值,会导致攻击者可伪造任意 Token。
|
|||
|
|
|
|||
|
|
### 执行步骤
|
|||
|
|
|
|||
|
|
#### Step 1:生成强密钥
|
|||
|
|
|
|||
|
|
在本地或任意安全环境中运行:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
python3 -c "import secrets, base64; print('JWT_ACCESS_SECRET=' + base64.urlsafe_b64encode(secrets.token_bytes(80)).rstrip(b'=').decode())"
|
|||
|
|
python3 -c "import secrets, base64; print('JWT_REFRESH_SECRET=' + base64.urlsafe_b64encode(secrets.token_bytes(80)).rstrip(b'=').decode())"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
或将以下内容保存为 `gen_keys.py` 后运行:
|
|||
|
|
```python
|
|||
|
|
import secrets, base64
|
|||
|
|
print("JWT_ACCESS_SECRET=" + base64.urlsafe_b64encode(secrets.token_bytes(80)).rstrip(b'=').decode())
|
|||
|
|
print("JWT_REFRESH_SECRET=" + base64.urlsafe_b64encode(secrets.token_bytes(80)).rstrip(b'=').decode())
|
|||
|
|
print("CHECKIN_TOKEN_SECRET=" + base64.urlsafe_b64encode(secrets.token_bytes(80)).rstrip(b'=').decode())
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Step 2:更新生产 .env 文件
|
|||
|
|
|
|||
|
|
将生成的密钥添加到生产环境的 `.env` 文件(注意:此文件不进 Git):
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
JWT_ACCESS_SECRET=<上面生成的字符串,107字符>
|
|||
|
|
JWT_REFRESH_SECRET=<上面生成的字符串,107字符>
|
|||
|
|
CHECKIN_TOKEN_SECRET=<上面生成的字符串,107字符>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> ⚠️ **警告**:`.env` 文件必须通过安全渠道传递(如密码管理器、加密压缩包),禁止通过微信、QQ等明文传输。
|
|||
|
|
|
|||
|
|
#### Step 3:验证
|
|||
|
|
|
|||
|
|
部署后检查 Spring Boot 日志或启动输出,确认使用的是外部注入的密钥(而非 `changeme-...`)。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 验证清单
|
|||
|
|
|
|||
|
|
修复完成后,请逐一验证以下场景:
|
|||
|
|
|
|||
|
|
| 漏洞 | 验证方法 |
|
|||
|
|
|------|---------|
|
|||
|
|
| C-01 | 使用 Operator A 的 Token 调用 `PUT /operator/OPERATOR_B_ID/disable`,应返回 403 |
|
|||
|
|
| C-02 | 使用无 `super_admin` 权限的 Operator Token 调用 `POST /operator/operators`,应返回 403 |
|
|||
|
|
| C-03 | 使用伪造 userId 的 WebSocket 连接,应被拒绝(CloseStatus.NOT_ACCEPTABLE) |
|
|||
|
|
| C-04 | 检查 `.env` 或启动参数中 JWT_SECRET 是否为随机强密钥(而非 `dev-access-secret...`) |
|