起因
开发权限代理的功能,基于 session 维护了权限代理后用户信息,存储用户信息的userId。后边上测试环境,检查用户代理之后的数据权限,发现数据和代理后用户所拥有的角色的数据权限对不上。
仔细排查接口,发现系统之前一些接口设计是直接传递 userId 查询数据,没有基于当前用户登录状态信息 userId 来查询,会出现数据安全问题。
为了解决这个问题,统一处理接口参数替换,专门写了一个过滤器,如果接口请求方式为 GET 或者 POST,并且请求参数中存在 userId 参数查询数据的,经过过滤器会替换成当前服务器上 session 维护的用户信息(包括两种情况:代理过和没代理过的),接着用包裹后的 ServletHttpRequest 对象进行后续的请求。具体代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
|
@Slf4j
@Component
@Order
public class PermissionProxyAuthorizerIdFilter implements Filter {
public static final String USER_ID = "userId";
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
RepeatableReadHttpServletRequestWrapper requestWrapper = new RepeatableReadHttpServletRequestWrapper(httpRequest);
// 替换 URL 参数
replaceUrlParameter(requestWrapper);
// 替换 RequestBody
replaceRequestBody(requestWrapper);
// 传递包装后的请求
chain.doFilter(requestWrapper, response);
}
private void replaceUrlParameter(RepeatableReadHttpServletRequestWrapper requestWrapper) {
String userId = requestWrapper.getParameter(USER_ID);
if (StringUtils.hasText(userId)) {
Long authorizerId = (Long) requestWrapper.getSession().getAttribute(PermissionProxyService.AUTHORIZER_ID);
if (authorizerId != null) {
requestWrapper.setParameter(USER_ID, String.valueOf(authorizerId));
log.info("URL参数 userId 替换: {} -> {}", userId, authorizerId);
}
}
}
private void replaceRequestBody(RepeatableReadHttpServletRequestWrapper requestWrapper) {
String contentType = requestWrapper.getContentType();
if (contentType != null && contentType.contains("application/json")) {
String body = requestWrapper.getBody();
if (StringUtils.hasText(body)) {
try {
JsonNode jsonNode = objectMapper.readTree(body);
if (jsonNode.has(USER_ID)) {
String userId = jsonNode.get(USER_ID).asText();
Long authorizerId = (Long) requestWrapper.getSession().getAttribute(PermissionProxyService.AUTHORIZER_ID);
if (authorizerId != null) {
((ObjectNode) jsonNode).put(USER_ID, authorizerId);
log.info("RequestBody userId 替换: {} -> {}", userId, authorizerId);
}
String newBody = objectMapper.writeValueAsString(jsonNode);
requestWrapper.setBody(newBody);
}
} catch (JsonProcessingException e) {
log.info("解析或修改 RequestBody 失败", e);
}
}
}
}
}
|
功能上测试环境之后,测试权限代理功能,切换用户的权限之后,请求某些数据查询的接口会有接口 400 响应码错误,后边发现这些请求大多数请求方式是 POST 和 PUT,最明确的一个接口报错是地图上传接口报错,响应如下:
1
2
3
4
5
6
7
|
{
"timestamp": "2025-11-19T04:44:58.142+00:00",
"status": 400,
"error": "Bad Request",
"message": "",
"path": "/map/uploadFile"
}
|
排查经过
HTTP 400响应码排查
咨询了一下 Deepseek,给出如下的排查思路。
HTTP 400响应码主要是客户端请求报文有误,包括:请求方法,URL格式,请求头和请求体等。 使用开发工具验证请求参数,关注 Content-Type 和 JSON语法以及协议一致性问题。
排查思路如下:
- 使用工具辅助排查。通过Postman或curl工具手动发送请求,排除前端代码干扰;
- 使用tcpdump或Wireshark抓包分析,查看实际发送的HTTP请求内容;
- 启用Spring Boot的调试日志级别,获取更详细的请求解析过程信息。
实际排查过程:先使用 Postman 测试接口,完整请求参数附上,还是会报 400 响应码。
使用控制变量法,我先注释掉过滤器的代码,重新上测试部署,发现文件上传接口一切正常,看来是过滤器的代码出现问题,但是从实际上来看没看出哪个地方有问题。
过滤器代码排查
将过滤器代码扔给 Deepseek,给出如下的解释:
当请求经过过滤器时,如果过滤器读取了请求体(即使只是读取参数),就会导致:
multipart/form-data请求的边界信息被破坏
- Spring的
MultipartResolver无法正确解析文件部分
@RequestParam("file")找不到对应的文件数据
后续经过仔细排查和查找日志之后,发现了一条 WARN 级别日志:
1
|
-- WARN -- [http-nio-8081-exec-10] -- o.s.w.s.m.support.DefaultHandlerExceptionResolver:208 --- Resolved [org.springframework.web.multipart.support.MissingServletRequestPartException: Required request part 'file' is not present]
|
理解文件上传报错和包装器影响
RepeatableReadHttpServletRequestWrapper的工作原理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public class RepeatableReadHttpServletRequestWrapper extends HttpServletRequestWrapper {
private final ByteArrayOutputStream cachedContent;
public ContentCachingRequestWrapper(HttpServletRequest request) {
super(request);
// 在构造函数中就可能开始缓存内容
this.cachedContent = new ByteArrayOutputStream();
// 可能会调用getInputStream()或getReader()来读取内容
}
@Override
public ServletInputStream getInputStream() throws IOException {
// 读取并缓存输入流,破坏原始multipart数据
// ... 实现细节
}
}
|
破坏过程:
- 包装器创建时调用
getInputStream()
- 读取整个请求体到内存缓存
- multipart边界标记被当作普通文本处理
- Spring后续无法识别文件部分的开始和结束
解决方案
确保过滤器中没有任何参数读取操作影响文件上传:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
@Component
public class YourFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
if (isMultipartRequest(httpRequest)) {
// 对于multipart请求,绝对不要调用以下方法:
// httpRequest.getParameter()
// httpRequest.getParameterMap()
// httpRequest.getParameterNames()
// httpRequest.getParameterValues()
// httpRequest.getReader()
// httpRequest.getInputStream()
chain.doFilter(request, response);
return;
}
// 只有普通请求才进行参数处理
String token = httpRequest.getParameter("token"); // 这只对普通请求有效
// ... 其他过滤逻辑
chain.doFilter(request, response);
}
private boolean isMultipartRequest(HttpServletRequest request) {
String contentType = request.getContentType();
return contentType != null && contentType.startsWith("multipart/");
}
}
|
可以看到之前过滤器中,无论是否替换 userId 都是使用包装器进行后续的请求,这会导致multipart/form-data请求的边界信息被破坏,可以延迟包装器创建,如果替换成功创建包装器并且使其进入后续请求中,如果替换失败,保持原来的请求体,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
|
@Slf4j
@Component
@Order
public class PermissionProxyAuthorizerIdFilter implements Filter {
public static final String USER_ID = "userId";
private final ObjectMapper objectMapper = new ObjectMapper();
@Resource
private AuthorizerIdFilterWhitelistMatcher authorizerIdFilterWhitelistMatcher;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
if (authorizerIdFilterWhitelistMatcher.isWhitelisted(requestURI)) {
// 白名单路径,直接放行
chain.doFilter(request, response);
return; }
RepeatableReadHttpServletRequestWrapper requestWrapper = replaceUserId(httpRequest);
if (requestWrapper != null) {
chain.doFilter(requestWrapper, response);
} else {
chain.doFilter(request, response);
}
}
private RepeatableReadHttpServletRequestWrapper replaceUserId(HttpServletRequest httpRequest) throws IOException {
Long authorizerId = (Long) httpRequest.getSession().getAttribute(PermissionProxyService.AUTHORIZER_ID);
if (authorizerId == null) {
return null; // 没有权限代理,不用替换
}
String userId = httpRequest.getParameter(USER_ID);
if (StringUtils.hasText(userId)) {
RepeatableReadHttpServletRequestWrapper requestWrapper = new RepeatableReadHttpServletRequestWrapper(httpRequest);
requestWrapper.setParameter(USER_ID, String.valueOf(authorizerId));
log.info("URL参数 userId 替换: {} -> {}", userId, authorizerId);
return requestWrapper;
} else {
String contentType = httpRequest.getContentType();
if (contentType != null && contentType.contains("application/json")) {
RepeatableReadHttpServletRequestWrapper requestWrapper = new RepeatableReadHttpServletRequestWrapper(httpRequest);
String body = requestWrapper.getBody();
if (StringUtils.hasText(body)) {
try {
JsonNode jsonNode = objectMapper.readTree(body);
if (jsonNode.has(USER_ID)) {
userId = jsonNode.get(USER_ID).asText();
if (jsonNode.has(USER_ID)) {
userId = jsonNode.get(USER_ID).asText();
((ObjectNode) jsonNode).put(USER_ID, authorizerId);
log.info("RequestBody userId 替换: {} -> {}", userId, authorizerId);
String newBody = objectMapper.writeValueAsString(jsonNode);
requestWrapper.setBody(newBody);
}
}
} catch (Exception e) {
log.error("解析或修改 RequestBody 失败", e);
}
}
return requestWrapper;
}
}
return null;
}
}
|
此外,添加白名单过滤类 AuthorizerIdFilterWhitelistMatcher
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
@Data
@Component
@ConfigurationProperties("permissionProxy.whitelist")
public class AuthorizerIdFilterWhitelistMatcher {
private final AntPathMatcher pathMatcher = new AntPathMatcher();
// 定义白名单路径列表
private List<String> paths;
/**
* 检查请求路径是否在白名单中
* @param requestURI 请求路径
* @return 是否为白名单路径
*/
public boolean isWhitelisted(String requestURI) {
boolean flag = paths.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, requestURI));
return flag;
}
}
|
总结
此次接口响应码 400 错误主要是对multipart请求使用请求体读取操作导致的,排查后修改逻辑也是先判断请求类型,再决定是否读取请求体以及使用包装器进行后续请求。总的来说,注意以下几点:
- ✅ 先检查请求类型,再决定是否包装请求体
- ✅ multipart请求使用原始
request请求体
- ✅ 非multipart请求才使用包装器
- ❌ 不要在检查前创建任何包装器
- ❌ 不要对multipart请求使用任何请求体读取操作
附录
参考文献
版权信息
本文原载于kitebin.top,遵循CC BY-NC-SA 4.0协议,复制请保留原文出处。