概述

在这个教程中,我们将学习如何在Spring Boot应用中修改HTTP请求,使其在到达控制器之前进行处理。Web应用和RESTful web服务经常采用这种方法来处理常见的问题,如对进入的实际控制器之前的HTTP请求进行转换或增强。这有助于实现松耦合,并显著减少开发工作量。

2. 过滤器修改请求

通常,应用程序需要执行通用操作,如身份验证、日志记录、转义HTML字符等。过滤器是处理运行在任何servlet容器中的应用程序这些通用问题的优秀选择。让我们来看看过滤器的工作原理:

在Spring Boot应用中,可以注册过滤器以按特定顺序执行,例如:

  • 修改请求
  • 记录请求
  • 检查请求的认证或恶意脚本
  • 决定是否拒绝或转发请求给下一个过滤器或控制器

假设我们想要从HTTP请求体中转义所有HTML字符,以防止跨站脚本(XSS)攻击。首先,让我们定义一个过滤器:

@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(1)注解中的值1表示所有HTTP请求首先通过EscapeHtmlFilter。我们也可以使用Spring Boot配置类中的FilterRegistrationBean来注册过滤器。这样,还可以定义过滤器的URL模式。

doFilter()方法将原始ServletRequest包装在一个自定义包装器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();
            }
        //Other implemented methods...
        };
        return servletInputStream;
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }
}

自定义包装器是必要的,因为我们不能直接修改原始HTTP请求。如果没有这个,servlet容器会拒绝请求

在自定义包装器中,我们重写了getInputStream()方法,返回一个新的ServletInputStream。基本上,我们在其中使用escapeHtml()方法对请求体进行修改后,分配了新的内容。

接下来,定义一个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;
    }
}

对于这个演示,控制器在*/save*端点上接收并返回接收到的请求体user

让我们看看过滤器是否起作用:

@Test
void givenFilter_whenEscapeHtmlFilter_thenEscapeHtml() throws Exception {

    Map<String, String> requestBody = Map.of(
      "name", "James Cameron",
      "email", "<script>alert()</script>[email protected]"
    );

    Map<String, String> expectedResponseBody = Map.of(
      "name", "James Cameron",
      "email", "&lt;script&gt;alert()&lt;/script&gt;[email protected]"
    );

    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)));
}

很好,过滤器成功地在请求到达UserController类定义的/saveURL之前转义了HTML字符。

3. 使用Spring AOP

Spring框架的RequestBodyAdvice接口和@RestControllerAdvice注解帮助我们在Spring应用中的所有REST控制器上应用全局建议。让我们用它们来在请求到达控制器之前转义HTTP请求中的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请求到达控制器之前被调用。因此,我们在这里对HTML字符进行转义。support()方法返回true,这意味着建议适用于所有REST控制器。

让我们看看它是否有效:

@Test
void givenAspect_whenEscapeHtmlAspect_thenEscapeHtml() throws Exception {

    Map<String, String> requestBody = Map.of(
      "name", "James Cameron",
      "email", "<script>alert()</script>[email protected]"
    );

    Map<String, String> expectedResponseBody = Map.of(
      "name", "James Cameron",
      "email", "&lt;script&gt;alert()&lt;/script&gt;[email protected]"
    );

    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注解,以便更细粒度地在控制器方法上应用建议。

4. 使用拦截器修改请求

Spring拦截器是一种可以在控制器处理请求之前拦截入站HTTP请求的类。拦截器用于多种目的,如身份验证、授权、日志记录和缓存。**此外,拦截器是特定于Spring MVC框架的,它们可以访问Spring的ApplicationContext**。

让我们看看拦截器是如何工作的:

DispatcherServlet将HTTP请求转发给拦截器。然后,拦截器处理后,可以将请求转发到控制器或拒绝它。因此,人们普遍认为拦截器可以改变HTTP请求。然而,我们将展示这种观点是错误的。

考虑我们在前面章节中讨论的从HTTP请求中转义HTML字符的例子。让我们看看能否用Spring MVC拦截器实现这一点:

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);
    }
}

所有拦截器都必须实现HandleInterceptor接口。在拦截器中,preHandle()方法在请求转发到目标控制器之前被调用。因此,我们把HttpServletRequest对象包装在EscapeHtmlRequestWrapper中,负责转义HTML字符。

此外,我们还需要将拦截器注册到适当的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 HtmlEscapeRequestInterceptor()).addPathPatterns("/**");

        WebMvcConfigurer.super.addInterceptors(registry);
    }
}

可以看到,WebMvcConfiguration类实现了WebMvcConfigurer。在类中,我们重写了addInterceptors()方法。在方法中,我们使用addPathPatterns()方法为所有入站HTTP请求注册了拦截器EscapeHtmlRequestInterceptor

令人惊讶的是,HtmlEscapeRequestInterceptor未能转发修改后的请求体并调用/save处理器:

@Test
void givenInterceptor_whenEscapeHtmlInterceptor_thenEscapeHtml() throws Exception {
    Map<String, String> requestBody = Map.of(
      "name", "James Cameron",
      "email", "<script>alert()</script>[email protected]"
    );

    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失败。因此,尽管拦截器可以像过滤器一样工作,但它们不适合修改HTTP请求。相反,当需要修改Spring应用程序上下文中的对象时,它们更有用

5. 结论

在这篇文章中,我们讨论了在Spring Boot应用中在请求到达控制器之前修改HTTP请求体的不同方法。虽然普遍认为拦截器可以做到这一点,但我们看到它并未成功。然而,我们看到了过滤器和AOP如何成功地在请求到达控制器之前修改HTTP请求体。

如往常一样,示例代码可以在GitHub上找到。