在 Spring Boot 中修改请求
1、概览
本文将带你了解如何在 HTTP 请求到达 Spring Boot 应用的 Controller 之前对其进行修改。Web 应用和 RESTful Web 服务经常使用这种方式来解决常见问题,例如在传入的 HTTP 请求到达实际 Controller 之前对其进行转换或过滤。这促进了松散耦合,大大减少了开发工作量。
2、使用 Filter
通常,应用需要执行一些通用的操作,如身份认证、日志记录、转义 HTML 字符等。Filter
是解决在任何 Servlet 容器中运行的应用的这些通用问题的最佳选择。
Filter 工作方式如下:
在 Spring Boot 应用中,以固定顺序注册 Filter,以实现以下目的:
- 修改请求
- 记录请求日志
- 检查请求是否经过认证或是否存在恶意脚本
- 决定拒绝或将请求转发给下一个 Filter 或 Controller
假设我们要转义 HTTP 请求体中的所有 HTML 字符,以防止 XSS 攻击。
首先定义 Filter
:
@Component
@Order(1)
public class EscapeHtmlFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
filterChain.doFilter(new HtmlEscapeRequestWrapper((HttpServletRequest) servletRequest), servletResponse);
}
}
@Order
注解中的 value
值 1
表示所有 HTTP 请求首先通过 EscapeHtmlFilter
Filter。还可以在 Spring Boot 配置类中定义 FilterRegistrationBean
Bean 来注册 Filter,这可以为 Filter 定义 URL 模式。
doFilter()
方法将原始 ServletRequest
包装在自定义 Wrapper EscapeHtmlRequestWrapper
中:
public class EscapeHtmlRequestWrapper extends HttpServletRequestWrapper {
private String body = null;
public HtmlEscapeRequestWrapper(HttpServletRequest request) throws IOException {
super(request);
this.body = this.escapeHtml(request);
}
@Override
public ServletInputStream getInputStream() throws IOException {
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());
ServletInputStream servletInputStream = new ServletInputStream() {
@Override
public int read() throws IOException {
return byteArrayInputStream.read();
}
// 其他的实现方法 ...
};
return servletInputStream;
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
}
由于原始 HTTP 请求无法修改,所以使用 Wrapper,否则 Servlet 容器会拒绝请求。
在自定义 Wrapper 中,覆写了 getInputStream()
方法,以返回一个新的 ServletInputStream
。基本逻辑是,在构造方法中使用 escapeHtml()
方法转义原始请求体中的 HTML 字符后保存到 body
字符串。
定义 UserController
类:
@RestController
@RequestMapping("/")
public class UserController {
@PostMapping(value = "save")
public ResponseEntity<String> saveUser(@RequestBody String user) {
logger.info("save user info into database");
ResponseEntity<String> responseEntity = new ResponseEntity<>(user, HttpStatus.CREATED);
return responseEntity;
}
}
上述示例中的 Controller 原样返回它在 /save
端点上接收到的请求体 user
。
试试看 Filter 是否生效:
@Test
void givenFilter_whenEscapeHtmlFilter_thenEscapeHtml() throws Exception {
Map<String, String> requestBody = Map.of(
"name", "James Cameron",
"email", "<script>alert()</script>james@gmail.com"
);
Map<String, String> expectedResponseBody = Map.of(
"name", "James Cameron",
"email", "<script>alert()</script>james@gmail.com"
);
ObjectMapper objectMapper = new ObjectMapper();
mockMvc.perform(MockMvcRequestBuilders.post(URI.create("/save"))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(MockMvcResultMatchers.status().isCreated())
.andExpect(MockMvcResultMatchers.content().json(objectMapper.writeValueAsString(expectedResponseBody)));
}
测试通过。Filter
在进入 UserController
中定义的 /save
端点之前,成功地转义了请求体中的 HTML 字符。
3、使用 Spring AOP
Spring 的 RequestBodyAdvice
接口和 @RestControllerAdvice
注解可帮助将全局 Advice 应用于 Spring 应用中的所有 REST Controller。
使用它们在 HTTP 请求到达 Controller 之前转义 HTML 字符:
@RestControllerAdvice
public class EscapeHtmlAspect implements RequestBodyAdvice {
@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage,
MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
InputStream inputStream = inputMessage.getBody();
return new HttpInputMessage() {
@Override
public InputStream getBody() throws IOException {
return new ByteArrayInputStream(escapeHtml(inputStream).getBytes(StandardCharsets.UTF_8));
}
@Override
public HttpHeaders getHeaders() {
return inputMessage.getHeaders();
}
};
}
@Override
public boolean supports(MethodParameter methodParameter,
Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage,
MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return body;
}
@Override
public Object handleEmptyBody(Object body, HttpInputMessage inputMessage,
MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return body;
}
}
beforeBodyRead()
方法会在 HTTP 请求到达 Controller 之前被调用。在其中转义了 HTML 字符。support()
方法返回 true
,这意味着会将 Advice 应用于所有 REST Controller。
测试:
@Test
void givenAspect_whenEscapeHtmlAspect_thenEscapeHtml() throws Exception {
Map<String, String> requestBody = Map.of(
"name", "James Cameron",
"email", "<script>alert()</script>james@gmail.com"
);
Map<String, String> expectedResponseBody = Map.of(
"name", "James Cameron",
"email", "<script>alert()</script>james@gmail.com"
);
ObjectMapper objectMapper = new ObjectMapper();
mockMvc.perform(MockMvcRequestBuilders.post(URI.create("/save"))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(MockMvcResultMatchers.status().isCreated())
.andExpect(MockMvcResultMatchers.content().json(objectMapper.writeValueAsString(expectedResponseBody)));
}
符合预期,所有 HTML 字符都被转义了。
还可以创建自定义 AOP 注解,用于 Controller 方法,以更精细的方式应用 Advce。详情可以参考 “在 Spring Boot 中通过 RequestBodyAdvice 统一解码请求体” 一文。
4、使用 Interceptor
Spring Interceptor(拦截器)可以拦截传入的 HTTP 请求,并在 Controller 处理这些请求之前对其进行处理。拦截器有多种用途,如身份认证、授权、日志记录和缓存。此外,拦截器是 Spring MVC 框架的特有功能,它可以访问 Spring ApplicationContext
。
Interceptor 工作方式如下:
DispatcherServlet
会将 HTTP 请求转发给 Interceptor。在处理之后,Interceptor 可以将请求转发给 Controller 或拒绝该请求。但是,在 Interceptor 中,不能更改 HTTP 请求。
让我们来看看前面讨论过的从 HTTP 请求中转义 HTML 字符的例子。让我们看看能否用 Spring MVC 拦截器实现这一功能:
以上述 “转义 HTML 字符” 的例子来说,尝试在 Interceptor 中应用 Wrapper:
public class EscapeHtmlRequestInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HtmlEscapeRequestWrapper htmlEscapeRequestWrapper = new HtmlEscapeRequestWrapper(request);
return HandlerInterceptor.super.preHandle(htmlEscapeRequestWrapper, response, handler);
}
}
所有 Interceptor 都必须实现 HandleInterceptor
接口。在拦截器中,preHandle()
方法会在请求转发到目标 Controller 之前被调用。因此,这里尝试用 EscapeHtmlRequestWrapper
对 HttpServletRequest
对象进行了封装,这样就能对 HTML 字符进行转义处理。
此外,还必须在 WebMvcConfigurer.addInterceptors
方法中注册拦截器并指定要拦截的 URL 模式:
@Configuration
@EnableWebMvc
public class WebMvcConfiguration implements WebMvcConfigurer {
private static final Logger logger = LoggerFactory.getLogger(WebMvcConfiguration.class);
@Override
public void addInterceptors(InterceptorRegistry registry) {
logger.info("addInterceptors() called");
registry.addInterceptor(new EscapeHtmlRequestInterceptor()).addPathPatterns("/**");
WebMvcConfigurer.super.addInterceptors(registry);
}
}
如上,WebMvcConfiguration
类实现了 WebMvcConfigurer
。在该类中,覆写了 addInterceptors()
方法。在该方法中,使用 addPathPatterns()
方法注册了拦截器 EscapeHtmlRequestInterceptor
,拦截所有传入的 HTTP 请求。
测试,你会发现 EscapeHtmlRequestInterceptor
并未按照预期生效:
@Test
void givenInterceptor_whenEscapeHtmlInterceptor_thenEscapeHtml() throws Exception {
Map<String, String> requestBody = Map.of(
"name", "James Cameron",
"email", "<script>alert()</script>james@gmail.com"
);
ObjectMapper objectMapper = new ObjectMapper();
mockMvc.perform(MockMvcRequestBuilders.post(URI.create("/save"))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestBody)))
.andExpect(MockMvcResultMatchers.status().is4xxClientError());
}
上述测试在 HTTP 请求体中添加了几个 JavaScript 字符。不料请求失败,HTTP 错误码为 400。因此,虽然拦截器可以像 Filter
一样发挥作用,但它并不适合修改 HTTP 请求。相反,当需要修改 Spring Application Context 中的对象时,拦截器才会派上用场。
5、总结
本文介绍了在 Spring Boot 中如何通过 Filter(过滤器)、RequestBodyAdvice(AOP) 以及 Interceptor(拦截器)来修改请求,以及各种方式之间的差异。
Ref:https://www.baeldung.com/spring-boot-change-request-body-before-controller