在 Spring Boot 中修改请求

1、概览

本文将带你了解如何在 HTTP 请求到达 Spring Boot 应用的 Controller 之前对其进行修改。Web 应用和 RESTful Web 服务经常使用这种方式来解决常见问题,例如在传入的 HTTP 请求到达实际 Controller 之前对其进行转换或过滤。这促进了松散耦合,大大减少了开发工作量。

2、使用 Filter

通常,应用需要执行一些通用的操作,如身份认证、日志记录、转义 HTML 字符等。Filter 是解决在任何 Servlet 容器中运行的应用的这些通用问题的最佳选择。

Filter 工作方式如下:

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 注解中的 value1 表示所有 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", "&lt;script&gt;alert()&lt;/script&gt;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", "&lt;script&gt;alert()&lt;/script&gt;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 工作方式如下:

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 之前被调用。因此,这里尝试用 EscapeHtmlRequestWrapperHttpServletRequest 对象进行了封装,这样就能对 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