kagamihogeの日記

kagamihogeの日記です。

Hibernate Validatorの実行順序と制御

概要

Hibernate Validatorで複数のconstraint annotationを指定順序で実行し、また、あるannotationが失敗したらそこでvalidationを終了する方法について。

背景

spring-boot-starter-validation というかHibernate Validatorはconstraint annotationにデフォルトでは特定の実行順序は無い。また、すべてのannotationを実行する。なので、たとえば一番最初の@NotEmptyがパスした場合のみ後続の@Sizeに進む、などは追加設定が必要となる。

5.3. Defining group sequences

By default, constraints are evaluated in no particular order, regardless of which groups they belong to.

https://docs.jboss.org/hibernate/validator/9.0/reference/en-US/html_single/#section-defining-group-sequences より抜粋

Hibernate Validatorの機能

最近ではHibernate Validatorを直接使うことはあまり無いとは思うが、まずはこのライブラリのみで機能を確認する。

指定グループのconstraint annotationのみvalidate

各constraint annotationのgroupsで所属グループを指定すると、validation実行時に指定したグループのみ実行される。

5.1. Requesting groups

Groups allow you to restrict the set of constraints applied during validation. One use case for validation groups are UI wizards where in each step only a specified subset of constraints should get validated. The groups targeted are passed as var-arg parameters to the appropriate validate method.

https://docs.jboss.org/hibernate/validator/9.0/reference/en-US/html_single/#_requesting_groups より抜粋

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;

public class Car {
  // コンストラクタ等は省略
  @NotEmpty(groups = First.class)
  @Min(value = 1, groups = Second.class)
  @Max(value = 2, groups = Second.class)
  @Pattern(regexp = "[0-9]{2}", groups = Third.class)
  private String id;
}

上記のgroupsには単なるmarker annotationを指定する。

public interface First { }
public interface Second {  }
public interface Third {  }

Validator#validateの第二引数Class<?>... groupsに上記のグループを示すmarker annotationを指定する。

import jakarta.validation.Validator;

// validatorの初期化とかは省略
Car car = new Car(null);
Set<ConstraintViolation<Car>> se = validator.validate(car, First.class);

上記のようにすると、Firstグループに含まれる@NotEmptyのみが実行される。

また、あるグループに含まれるannotationはすべて実行される。

Car car = new Car("");
Set<ConstraintViolation<Car>> se = validator.validate(car, Second.class);
// must be greater than or equal to 1
// must be less than or equal to 2

グループ未指定の場合は暗黙的にjakarta.validation.groups.Defaultグループに属する。なので、最初に書いた「すべてのannotationを実行する」はより正確には「Defaultグループに属するすべてのannotationを実行する」となる。

既定の実行順序は無いので引数のグループ指定順は特に意味を持たない。また、全部実行するので制御もできない。以下のように指定しても、順序は不定だが3グループとも実行される点に変わりは無い。

Car car = new Car("a");
Set<ConstraintViolation<Car>> se = validator.validate(car, Third.class, Second.class, First.class);
// must be greater than or equal to 1
// must match "[0-9]{2}"
// must be less than or equal to 2

指定グループ順実行と失敗時の即時リターン

@GroupSequence でグループの実行順序を指定できる。また、あるグループ内のannotationが一つでも失敗すると後続グループは実行されない。

5.3. Defining group sequences

In order to implement such a validation order you just need to define an interface and annotate it with @GroupSequence, defining the order in which the groups have to be validated (see Example 5.7, “Defining a group sequence”). If at least one constraint fails in a sequenced group, none of the constraints of the following groups in the sequence get validated.

https://docs.jboss.org/hibernate/validator/9.0/reference/en-US/html_single/#section-defining-group-sequences より抜粋

@GroupSequenceを付与するinterfaceを作成して実行したい順序でグループを指定する。

import jakarta.validation.GroupSequence;

@GroupSequence({First.class, Second.class, Third.class})
public interface CarSequence {
}

validateの第二引数に上記で作成したinterfaceを指定する。

Car car = new Car("");
Set<ConstraintViolation<Car>> se = validator.validate(car, CarSequence.class);
// must not be empty
Set<ConstraintViolation<Car>> se = validator.validate(car, First.class, Second.class, Third.class);
//must not be empty
//must be greater than or equal to 1
//must be less than or equal to 2
//must match "[0-9]{2}"

@GroupSequenceの場合と比べると分かるように、FirstでストップしてThridまでは進んでないのが分かる。

spring-boot

@Validatedに@GroupSequenceを指定して順序制御

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/validation/annotation/Validated.html#value() にあるとおり、ここにグループを指定できる。

以下コード例のうち、@GroupSequenceの付与interfaceなど既述のものは省略している。

import org.springframework.validation.annotation.Validated;
import org.springframework.validation.BindingResult;

@RestController
public class SampleController {
  @PostMapping("/car")
  public Car car(
      @Validated(CarSequence.class)
      @RequestBody Car car,
      BindingResult result) {
  // 省略

メモ

セキュア・バイ・デザイン: 安全なソフトウェア設計を読んだ際、入力値の検証順は広く合致するものから狭いもので行い、かつ、失敗した時点で終了が良い、という記述があった。まぁそうよね、と思いspringのbean validationでソレを実装するにはどうやるんだっけ? がこれを調べようとした切っ掛けではある。

たとえば、以下のようなannotationだとまぁまぁ危険だと思われる。ややこしい正規表現だけどある長さ以下なら安全、というのを安直に以下のように実装した場合、このエントリで述べたようにすべてのannotationを実行するので攻撃が成功してしまうかもしれない。

  @Max(value = 5)
  @Pattern(regexp = "5文字を越えると危険な正規表現")
  private String value;

私は正規表現による攻撃に詳しくないので実際のところこれがどのくらい危険かは断言できないんだが……たとえば https://stackoverflow.com/questions/53048859/is-java-redos-vulnerable によると以下のようなリスキーな正規表現の紹介がある。安直に @Pattern(regexp=... でこういう正規表現を使ってしまうのは危ないよなぁ……と感じた次第である。

The following will hang in Java 17: Pattern.compile("(.*a){10000}").matcher("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!").matches();

また、それならvalidation処理はなぜ早期リターンしないのだろうか……? という疑問もある。どっかでドキュメント見た気もするが見当たらなくて想像になるが、Web API以外も想定した汎用ライブラリだかだと思われる。

例えば、入力フォームでvalidationするとして、入力必須 -> じゃあ入力するよ -> 長さエラー -> じゃあ修正して -> 正規表現エラー -> じゃあ……、とか最悪なUIだろう。validation結果を全部返してそれをどうするかアプリケーション次第ですよ、は汎用ライブラリの設計方針としては正しいように思われる。

まぁWeb全盛の時代に合ってるかどうかはまた別問題ではあるが。