😺

[SpringSecurity]RequestRejectedHandlerでハンドリングされない件

に公開

使用バージョン

+--- org.springframework.boot:spring-boot-starter-security -> 3.3.2
|    +--- org.springframework.security:spring-security-config:6.3.1
|    |    +--- org.springframework.security:spring-security-core:6.3.1
|    |    |    +--- org.springframework.security:spring-security-crypto:6.3.1
|    \--- org.springframework.security:spring-security-web:6.3.1

不正なURLパラメータなどをハンドリングしたいときにRequestRejectedHandlerをBean化することがよくある。

@Bean
public RequestRejectedHandler requestRejectedHandler() {
    return new HttpStatusRequestRejectedHandler(HttpStatus.BAD_REQUEST.value());
}

しかし、RequestRejectedExceptionがExceptionHandlerの@ExceptionHandler(Exception.class)でキャッチされてしまうことがあった。

問題のリクエストは以下

curl -X POST -b 'test=てすと' http://localhost:8080/hoge
org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the header: "cookie " has a value "test=てすと" that is not allowed.
以下略

cookieには非ASCII文字は入れてはいけないが、完全クライアント都合なのでこちらでは4xx系を返したい。

中身を見てみると、StrictFirewalledRequest#validateAllowedHeaderValueの様子
https://github.com/spring-projects/spring-security/blob/91b0936189ef6dc266f1a7b7974e2157255faa14/web/src/main/java/org/springframework/security/web/firewall/StrictHttpFirewall.java#L841-L845

Tomcatがヘッダーを解釈するときにtest=てすとtest=てすと
されてしまいこの部分で非制御文字になってしまいエラーになる。
詳細は下の方に記載

SpringSecurityの機能ならFilterで処理されるからExceptionHandlerでキャッチされないのでは?と思うところ
ところがどっこい
Filterを通過して、
org.springframework.web.servlet.DispatcherServleまで動き、
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethodや、
org.springframework.http.server.ServletServerHttpRequest.getHeadersあたりまで動いている。
(要するに@ExceptionHandlerでキャッチできる範囲)


対策

@ExceptionHandler(RequestRejectedException.class)でハンドリングする

単純にExceptionを拾って任意のエラーを返す

@ExceptionHandler(RequestRejectedException.class)
public ResponseEntity<String> handleRequestRejectedException(Exception e) {
    return ResponseEntity.status(HttpStatus.BAD_REQUEST)
        .contentType(new MediaType(MediaType.APPLICATION_PROBLEM_JSON, StandardCharsets.UTF_8))
        .body(HttpStatus.BAD_REQUEST.getReasonPhrase());
}

StrictHttpFirewallのsetAllowedHeaderValuesをカスタマイズする

公式に記載がある通り、
ヘッダーをUTF-8変換後にチェックさせる。
HttpServletRequest.getCookies()で取得するときはCookieProcessor経由で取得されているようで、こっちはUTF-8に変換されている。
StrictFirewalledRequest経由で取得されないっぽい

@Bean
public StrictHttpFirewall httpFirewall() {
    StrictHttpFirewall firewall = new StrictHttpFirewall();
    Pattern allowed = Pattern.compile("\\p{IsAssigned}&&[^\\p{IsControl}]*");
    firewall.setAllowedHeaderValues(
        (header) -> {
            String parsed = new String(header.getBytes(ISO_8859_1), UTF_8);
            return allowed.matcher(parsed).matches();
        });
    return firewall;
}

TomcatでリクエストHeaderが解釈される仕組み

HttpHeaderParser#parseHeaderでヘッダーを読み込んでbyteで保持している
https://github.com/apache/tomcat/blob/1f1dcfc61322701cc00c87bde74895e11392fb2a/java/org/apache/tomcat/util/http/parser/HttpHeaderParser.java#L263

headerData.headerValueの中身はMessageBytesで、
https://github.com/apache/tomcat/blob/main/java/org/apache/tomcat/util/buf/MessageBytes.java#L41
toStringTypeでByteChunkのtoStringを呼び出している。
https://github.com/apache/tomcat/blob/1f1dcfc61322701cc00c87bde74895e11392fb2a/java/org/apache/tomcat/util/buf/MessageBytes.java#L190-L205
https://github.com/apache/tomcat/blob/1f1dcfc61322701cc00c87bde74895e11392fb2a/java/org/apache/tomcat/util/buf/ByteChunk.java#L546-L562

で、ByteChunkのDEFAULT_CHARSETがStandardCharsets.ISO_8859_1なので、UTF-8で解釈されず文字化けする
デフォルトって書いてあるけど変更できなさそう
(コメントにUTF-8にしたいけど仕様だからって書いてますね)
https://github.com/apache/tomcat/blob/1f1dcfc61322701cc00c87bde74895e11392fb2a/java/org/apache/tomcat/util/buf/ByteChunk.java#L112-L116

Discussion