在 Spring Boot 中到達控制器之前修改請求正文
1. 概述
在本教程中,我們將學習如何在 HTTP 請求到達 Spring Boot 應用程式中的控制器之前對其進行修改。 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
表示所有 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()
方法轉義 HTML 字元後為其分配了修改後的請求正文。
讓我們定義一個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", "<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().isCreated())
.andExpect(MockMvcResultMatchers.content().json(objectMapper.writeValueAsString(expectedResponseBody)));
}
好吧,過濾器在到達UserController
類別中定義的 URL /save
之前成功轉義了 HTML 字元。
3.使用Spring AOP
Spring 框架的RequestBodyAdvice
介面以及註解@RestControllerAdvice
有助於將全域建議套用至 Spring 應用程式中的所有 REST 控制器。讓我們在 HTTP 請求到達控制器之前使用它們來轉義 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", "<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().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 請求正文到達控制器之前修改 HTTP 請求正文的各種方法。根據普遍的看法,攔截器可以幫助做到這一點,但我們看到它失敗了。然而,我們看到了過濾器和 AOP 如何在 HTTP 請求正文到達控制器之前成功修改它。
與往常一樣,範例的原始程式碼可在 GitHub 上取得。