HackToTech

Hack To Technology

BeanValidationでControlCharacterが含まれる場合をバリデーションする

リクエストとかでControlCharacterが混じっていると邪魔になるので弾きたかったという話
調べてもすぐにぱぱぱっと出てこなかったりして面倒なのでメモがてら書く

どちらも空文字は許可するようにしてある
テストはソースに書いてあるので興味があれば

import jakarta.validation.Constraint
import jakarta.validation.Payload
import jakarta.validation.constraints.Pattern
import kotlin.reflect.KClass

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [])
@Pattern(regexp = "^$|[^\\p{Cntrl}]+")
annotation class RejectControlCharacters(
    val message: String = "{com.example.validatecontrolcharacterssample.constraints.RejectControlCharacters.message}",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
)

改行だけは許可したいみたいな場合はこっち

import jakarta.validation.Constraint
import jakarta.validation.Payload
import jakarta.validation.constraints.Pattern
import kotlin.reflect.KClass

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [])
@Pattern(regexp = "^$|[^[\\x00-\\x09\\x0B\\x0C\\x0E-\\x1F\\x7F]]+")
annotation class RejectControlCharactersOtherThanLineBreak(
    val message: String = "{com.example.validatecontrolcharacterssample.constraints.RejectControlCharactersOtherThanLineBreak.message}",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
)

書いたソースはここ github.com

SpringでRollbackされた時に別トランザクションでデータの保存をしようとしてたのでメモ

大まかなコードのイメージ

package com.example.app.application

import com.example.app.domain.Sample
import com.example.app.domain.SampleRepository
import org.springframework.aop.framework.AopContext
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.interceptor.TransactionAspectSupport

@Service
class SampleService(
    private val otherService: OtherService,
    private val fallbackService: FallbackService,
) {
    @Transactional
    fun handle() {
        try {
            // ここの呼び出しがロールバックするなら処理を保存しときたい
           // 実際は他にもトランザクションを使ったなんらかの処理
            otherService.handle()
        } catch (e: RuntimeException) {
            // UnexpectedRollbackExceptionにならないように明示的に指定する必要がある
            val transactionStatus = TransactionAspectSupport.currentTransactionStatus()
            transactionStatus.setRollbackOnly()
            fallbackService.handle()
        }
    }
}

@Service
class OtherService {
    @Transactional
    fun handle() {
        // 実際はなんらかの処理
        throw RuntimeException("runtime error")
    }
}

@Service
class FallbackService(
    private val repository: SampleRepository,
) {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun handle() {
        // 実際はデータ受け取って書き込むだけ
        val sample = Sample.of()
        repository.save(sample)
    }
}

なんかこの為だけに別クラス作って呼び出したり、EventPublisher経由で別メソッドを呼び出したりするのがイマイチだなあと思ってたらAOPで解決する方法もあるとのことだった
www.youtube.com 詳解Springトランザクション -初級から上級まで- #jsug | ドクセル


別メソッドで Propagation.REQUIRES_NEW しているなら、トランザクション別で自動的に貼り直してくれれば良いのに
と思っていたらAOPの仕組み的に SampleService#handle に入る際にProxy経由で呼び出されて、
SampleService#handle 内で 別メソッドを呼び出しても割り込まれずに直接呼び出される(Annotationが効かない)という話っぽい
この辺ちゃんと頭の中でイメージ出来ていないと、なんで書いた通りに呼び出されないんだ???となるのでとても良い資料だった

解説されていたサイトの方、どうやってProxy経由で呼び出しているのかちょっとだけ見てみた

あとは SampleService#handle から SampleService(Proxy経由)#別メソッド を呼びだしている、ということっぽい

SpringBootでTomcatの特定のValveを削除する

タイトル通り
あんまり使い道はないと思われるが、ちょっと簡単に削除が出来るのか気になったので個人的なメモ
消すのはRemoteIpValve
設定の話はこの辺

バージョン

  • SpringBoot(2.7.5)
# application.properties
server.tomcat.remoteip.protocol-header=x-forwarded-for
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory
import org.springframework.boot.web.server.WebServerFactoryCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.apache.catalina.valves.RemoteIpValve


@Configuration
class TomcatConfig {
    @Bean
    fun remoteIpValveRemover(): WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
        return WebServerFactoryCustomizer { factory: TomcatServletWebServerFactory ->
            factory.engineValves.removeIf { it is RemoteIpValve }
        }
    }
}
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RestController
import javax.servlet.http.HttpServletRequest


@RestController
class BaseController {
    @GetMapping("/")
    fun base(
        request: HttpServletRequest,
        @RequestHeader("X-Forwarded-For") xForwardedFor: String?,
    ): ResponseEntity<Any> {
        println("X-Forwarded-For: $xForwardedFor")
        return ResponseEntity.ok(request.remoteAddr)
    }
}

Removeした場合

curl http://localhost:8080/ -H 'X-Forwarded-For: 192.168.0.1'
127.0.0.1

# サーバ側のログ
X-Forwarded-For: 192.168.0.1

Removeしなかった場合

curl http://localhost:8080/ -H 'X-Forwarded-For: 192.168.0.1'
192.168.0.1

# サーバ側のログ
X-Forwarded-For: null

結論: 簡単に出来る

CVE-2022-45143周りを見たのでメモ

https://lists.apache.org/thread/yqkd183xrw3wqvnpcg3osbcryq85fkzjjvn.jp

仕事していて話題にあがったので暇つぶしがてら見てみた

とりあえず素のtomcatで試す

conf/server.xmlを書き換える(関係ない箇所は省略)

<Server port="8005" shutdown="SHUTDOWN">
  <Service name="Catalina">
    <Engine name="Catalina" defaultHost="localhost">
      <Host name="localhost"  appBase="webapps" unpackWARs="true" autoDeploy="true" errorReportValveClass="org.apache.catalina.valves.JsonErrorReportValve">
      </Host>
    </Engine>
  </Service>
</Server>

HostタグでerrorReportValveClassを指定することで切り替えできる(デフォルトは ErrorReportValve が使用される)

10.1.2で修正されているらしいので、 その辺りの挙動を一応見る(Commit)

apache-tomcat-10.1.1

curl http://localhost:8080/\"\"/ -H 'accept:application/json'

そのまま出力しようとするせいでJSONが壊れる

{
  "type": "Exception Report",
  "message": "Invalid character found in the request target [/""/ ]. The valid characters are defined in RFC 7230 and RFC 3986",
  "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing)."
}

apache-tomcat-10.1.4

curl http://localhost:8080/\"\"/ -H 'accept:application/json'

10.1系の最新であればちゃんとエスケープされる

{
  "type": "Exception Report",
  "message": "Invalid character found in the request target [/\u0022\u0022/ ]. The valid characters are defined in RFC 7230 and RFC 3986",
  "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing)."
}

spring-bootの埋め込みtomcatで試す

spring-bootの埋め込みtomcatの errorReportValveClass をどうやって書き換えるのか調べていたら、先にわかりやすく書いてくださっている方がいたのでそれをKotlinで書いて試した
参考にさせていただいたページ: Spring BootでTomcatのデフォルトエラーページが出るのを抑止する

package com.example.app.presentation.configuration

import org.apache.catalina.Context
import org.apache.catalina.core.StandardHost
import org.springframework.boot.web.embedded.tomcat.TomcatContextCustomizer
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory
import org.springframework.boot.web.server.WebServerFactoryCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.apache.catalina.valves.JsonErrorReportValve


@Configuration
class TomcatConfig {
    @Bean
    fun errorReportValveCustomizer(): WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
        return WebServerFactoryCustomizer { factory: TomcatServletWebServerFactory ->
            factory.addContextCustomizers(TomcatContextCustomizer { context: Context ->
                if (context.parent is StandardHost) {
                    (context.parent as StandardHost).errorReportValveClass = JsonErrorReportValve::class.qualifiedName
                }
            })
        }
    }
}

spring-boot-starter-web-2.7.5 + tomcat-embed-core-9.0.68

ちょっと他のプロジェクトでそのまま試していたのでstarterとかが古いが気にしない

curl http://localhost:8080/\"\"/ -H 'accept:application/json'

そのまま出力しようとするせいでJSONが壊れる

{
  "type": "Exception Report",
  "message": "Invalid character found in the request target [/""/ ]. The valid characters are defined in RFC 7230 and RFC 3986",
  "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing)."
}

spring-boot-starter-web-2.7.5 + tomcat-embed-core-9.0.69

9系は9.0.69で修正されている

curl http://localhost:8080/\"\"/ -H 'accept: application/json'
{
  "type": "Exception Report",
  "message": "Invalid character found in the request target [/\u0022\u0022/ ]. The valid characters are defined in RFC 7230 and RFC 3986",
  "description": "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing)."
}

まあデフォルトをそのまま使っていれば遭遇することはなさそうだし、バージョンあげれば修正されるしで気にしなくて良さそう感

Spring Bootのエラーページ周りを色々見たのでメモ

Spring bootを仕事で使っていて、
server.error.whitelabel.enabled を指定すると具体的にどうなるのかとか、
誰がどうレスポンスを生成しているのかとか、
この辺イマイチ浅い理解をしていてちゃんと説明出来そうにないのでちゃんとデバッガを動かしながら見てみた

読んでいたところのコードの各種バージョン

  • SpringBoot(2.7.6)
  • Tomcat(10.1.4)
  • SpringWebMvc(5.3.24)

理解する上で併せて読むと良い公式ブログ spring.io

server.error.whitelabel.enabled

trueを指定した場合(デフォルト)

WhitelabelErrorViewConfigurationが有効になる(defaultErrorViewのBeanが登録される)

ErrorMvcAutoConfiguration#WhitelabelErrorViewConfiguration

この状態でaccept: text/htmlのリクエストを投げた場合
Viewは↓で生成されたものが返される

ErrorMvcAutoConfiguration#StaticView

<html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>Sat Dec 10 16:54:07 JST 2022</div><div>There was an unexpected error (type=Not Found, status=404).</div></body></html>

この状態でapplication/jsonのリクエストを投げた場合

{"timestamp":"2022-12-10T07:59:51.927+00:00","status":404,"error":"Not Found","path":"/base/test"}

falseを指定した場合

この状態でaccept: text/htmlのリクエストを投げた場合
↓でTomcat側で生成されたものが返される(Tomcatを使っている場合) ErrorReportValve#report

<!doctype html><html lang="en"><head><title>HTTP Status 404 – Not Found</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 404 – Not Found</h1></body></html>

この状態でapplication/jsonのリクエストを投げた場合
trueの時と同じものが返される

{"timestamp":"2022-12-10T07:57:47.967+00:00","status":404,"error":"Not Found","path":"/base/test"}

上の部分のレスポンス返すまでどういう挙動をしているのかが気になったので、ErrorController周りを見ることにした

ErrorController周り

ErrorControllerBean が無ければ BasicErrorController が登録される ErrorMvcAutoConfiguration#basicErrorController

ErrorControllerのインタフェース

ErrorControllerを実装した抽象クラスのAbstractErrorController

AbstractErrorControllerを継承したBasicErrorController

BasicErrorControllerの中身

htmlとその他のAcceptHeaderで呼び分けているところ
AcceptHeadertext/htmlがあるならBasicErrorController#errorHTML

それ以外の場合はBasicErrorController#error

HttpMediaTypeNotAcceptableExceptionが起きた場合にエラーステータスをそのまま返すだけのExceptionHandlerも登録している

大体何をやっているかがわかったので、ErrorControllerを実装してカスタムなErrorControllerを書いてみた

ErrorControllerを実装したカスタムなErrorController

実装自体は少し BasicErrorController をコピーしつつjavaからKotlinに直している
errorのメソッドは特に書くことはなくて、
mediaTypeNotAcceptableのメソッドの方は、ResponseEntity<Any>で返すと最終的にDefaultHandlerExceptionResolverに捕まってレスポンスボディがnullになるので(後述)、ObjectMapper使ってResponseEntity<String>で返させている
そして、仮にval status = getStatus(request)statusを受け取って渡さなかった場合、HttpMediaTypeNotAcceptableのレスポンスコードとして用意されている406が返される
text/htmlだろうがなんだろうがJSON的なレスポンスを強制的に返したかったのでやっているが、本来は406を返しつつ受け取れるメディアタイプを返す実装の方が正しいとは思う
(ただまあ404なエンドポイントに投げられて、統一したレスポンスボディ返しつつ406ではなく404を返したいケースはある気がする)

import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.boot.web.servlet.error.ErrorController
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.HttpMediaTypeNotAcceptableException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import javax.servlet.RequestDispatcher
import javax.servlet.http.HttpServletRequest

@RestController
class ErrorController(
    private val objectMapper: ObjectMapper,
): ErrorController {

    @RequestMapping("/error")
    fun error(request: HttpServletRequest): ResponseEntity<Any> {
        val status = getStatus(request)
        val body = mapOf("message" to status.reasonPhrase, "status" to status.value())
        return ResponseEntity(body, status)
    }

    @ExceptionHandler(HttpMediaTypeNotAcceptableException::class)
    fun mediaTypeNotAcceptable(request: HttpServletRequest): ResponseEntity<String> {
        val status = getStatus(request)
        val body = mapOf("message" to status.reasonPhrase, "status" to status.value())
        return ResponseEntity(objectMapper.writeValueAsString(body), status)
    }

    private fun getStatus(request: HttpServletRequest): HttpStatus {
        val statusCode = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE) as Int? ?: return HttpStatus.INTERNAL_SERVER_ERROR
        return try {
            HttpStatus.valueOf(statusCode)
        } catch (ex: Exception) {
            HttpStatus.INTERNAL_SERVER_ERROR
        }
    }
}

accept application/json

curl -XGET http://localhost:8080/404 -H 'accept: application/json' -sS -v | jq
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /404 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.81.0
> accept: application/json
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 404 
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Sat, 10 Dec 2022 16:39:19 GMT
< 
{ [47 bytes data]
* Connection #0 to host localhost left intact
{
  "message": "Not Found",
  "status": 404
}

accept text/html

curl -XGET http://localhost:8080/404 -H 'accept: text/html' -sS -v | jq
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /404 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.81.0
> accept: text/html
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 404 
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Content-Type: text/html;charset=UTF-8
< Content-Length: 36
< Date: Sat, 10 Dec 2022 16:39:42 GMT
< 
{ [36 bytes data]
* Connection #0 to host localhost left intact
{
  "message": "Not Found",
  "status": 404
}

accept: text/html の場合にResponseEntity<Any>を返しても最終的にDefaultHandlerExceptionResolverに捕まってResponseのBodynullのステータス406で返ってくる部分

上のErrorControllerでエラーレスポンスを返した際に、 AbstractMessageConverterMethodProcessor#writeWithMessageConverters 辺りでmediaTypesapplication/json, application/*+jsonにマッチしない且つbodynullではないので、
HttpMediaTypeNotAcceptableException投げられることになる

で、その後にDefaultHandlerExceptionResolver側で捕捉されて、406,nullで返されることになる
DefaultHandlerExceptionResolver#doResolveException DefaultHandlerExceptionResolver#handleHttpMediaTypeNotAcceptable

実際にFramework側のコードを読みながら手を動かすと理解が進むので今後も暇を見つけてやっていきたい