SpringのBindingResult(Errors)をログ出力する時は適切にマスキングしよう

Spring(Spring MVC)を使用したWebアプリケーションでは、入力チェックのエラー情報は`BindingResult`インタフェースで表現され、以下のようにControllerのハンドラメソッドの引数として受け取ることができます。

```java
@PostMapping
public String change(@AuthenticationPrincipal AccountUserDetails userDetails,
    @Validated PasswordChangeForm form, BindingResult result) { // 
  if (result.hasErrors()) {
    return changeForm();
  }
  accountService.changePassword(userDetails.getAccount(), form.getNewPassword());
  return "welcome/home";
}
```

上記コードに問題ありませんが、以下のようなコードを追加してエラー情報をコンソールに出力してみましょう。

```java
System.out.println(result);
```

コンソールには以下のような情報が出力されます。

```
org.springframework.validation.BeanPropertyBindingResult: 6 errors
Field error in object 'passwordChangeForm' on field 'confirmPassword': rejected value [ccc]; codes [com.example.validation.Confirm.message.passwordChangeForm.confirmPassword,com.example.validation.Confirm.message.confirmPassword,com.example.validation.Confirm.message.java.lang.String,com.example.validation.Confirm.message]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [passwordChangeForm.confirmPassword,confirmPassword]; arguments []; default message [confirmPassword],org.springframework.validation.beanvalidation.SpringValidatorAdapter$ResolvableAttribute@41663a9a,PROPERTY,EQUAL,false,org.springframework.validation.beanvalidation.SpringValidatorAdapter$ResolvableAttribute@7dd16c6]; default message [must same value with "newPassword"]
Field error in object 'passwordChangeForm' on field 'newPassword': rejected value [bb]; codes [Password.passwordChangeForm.newPassword,Password.newPassword,Password.java.lang.String,Password]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [passwordChangeForm.newPassword,newPassword]; arguments []; default message [newPassword],true]; default message [must contain 1 or more special characters.]
Field error in object 'passwordChangeForm' on field 'newPassword': rejected value [bb]; codes [Password.passwordChangeForm.newPassword,Password.newPassword,Password.java.lang.String,Password]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [passwordChangeForm.newPassword,newPassword]; arguments []; default message [newPassword],true]; default message [must be 8 or more characters in length.]
Field error in object 'passwordChangeForm' on field 'newPassword': rejected value [bb]; codes [Password.passwordChangeForm.newPassword,Password.newPassword,Password.java.lang.String,Password]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [passwordChangeForm.newPassword,newPassword]; arguments []; default message [newPassword],true]; default message [must contain 1 or more uppercase characters.]
Field error in object 'passwordChangeForm' on field 'newPassword': rejected value [bb]; codes [Password.passwordChangeForm.newPassword,Password.newPassword,Password.java.lang.String,Password]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [passwordChangeForm.newPassword,newPassword]; arguments []; default message [newPassword],true]; default message [must contain 1 or more digit characters.]
Field error in object 'passwordChangeForm' on field 'newPassword': rejected value [bb]; codes [error.passwordChangeForm.newPassword,error.newPassword,error.java.lang.String,error]; arguments []; default message [error!]
```

エラーになった「フィールド」「実際の入力値」「エラーコード」「メッセージ引数」「デフォルトメッセージ」などが出力されるわけですが、**「実際の入力値(rejected value [ccc];の部分)」が出力される点に注意が必要です**。 上記の例だと**秘匿情報として扱うべき「パスワード」の値がそのまま出力されてしまっています。**
**つまり・・・入力チェックエラーをログ出力するような要件があった場合に、安易に`BindingResult#toString()`を使うとセキュリティ要件を満たさないアプリケーションになってしまうわけです。**

> **Note:**
>
> 上記例のように明示的に`BindingResult#toString()`を呼び出すコードを実装しなくても、同様の情報がログなどに記録される可能性があります。たとえば・・・、Controllerのハンドラメソッドの引数に`BindingResult`を指定しないと（＝省略すると）、Springは`BindException`または`MethodArgumentNotValidException`を発生させます。これらの例外のメッセージの中には`BindingResult#toString()`と同等の内容が含まれるため、例外ハンドラなどで無条件にログ出力するような実装になっていると、意図せず秘匿情報がログに出力されてしまいます。

# どうすればいいのか？

答えば簡単！ 商用環境で出力するログに「`BindingResult#toString()`」を使わない！！ということです。
UI向けのWebアプリでは入力チェックエラーをログに出力する！的な要件はあまりなさそうですが・・・システム間連携用のWebアプリ(Web API, REST API)などでは、エラー解析用にログ出力しておくことが求められるケースもあると思います。
そういった場合は、安易に「`BindingResult#toString()`」を使うのではなく、`BindingResult`がもつエラー情報から適切なログメッセージを作るようにしましょう。
また、例外ハンドラの実装では、`BindException`と`MethodArgumentNotValidException`を個別にハンドリングするようにしましょう。

# 秘匿情報をマスキングしてみる

ここでは、

* ログメッセージは「`BindingResult#toString()`」と同じフォーマット
* 秘匿情報の項目(passwordという単語を含む項目)の入力値をマスク
* マスク文字は「`*`」

という要件でログメッセージを組み立ててみます。

```java
final StringJoiner joiner = new StringJoiner("\n")
    .add(result.getClass().getName() + ":" + result.getErrorCount() + " errors");
result.getGlobalErrors().forEach(error -> joiner.add(error.toString()));
result.getFieldErrors().forEach(error -> {
  if (error.getField().toLowerCase().contains("password")) {
    String message = error.toString();
    int sIndex = message.indexOf("rejected value [") + 16;
    int eIndex = message.indexOf("]; codes");
    joiner.add(message.substring(0, sIndex) + IntStream.range(0, eIndex - sIndex)
        .mapToObj(value -> "*").collect(Collectors.joining()) + message.substring(eIndex));
  } else {
    joiner.add(error.toString());
  }
});
System.out.println(joiner.toString());
```

やや強引なドリブル（実装）な気はしていますがw、上記ロジックで組み立てたメッセージは以下のようになります。

```
org.springframework.validation.BeanPropertyBindingResult:6 errors
Field error in object 'passwordChangeForm' on field 'newPassword': rejected value [**]; codes [Password.passwordChangeForm.newPassword,Password.newPassword,Password.java.lang.String,Password]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [passwordChangeForm.newPassword,newPassword]; arguments []; default message [newPassword],true]; default message [must contain 1 or more uppercase characters.]
Field error in object 'passwordChangeForm' on field 'newPassword': rejected value [**]; codes [Password.passwordChangeForm.newPassword,Password.newPassword,Password.java.lang.String,Password]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [passwordChangeForm.newPassword,newPassword]; arguments []; default message [newPassword],true]; default message [must be 8 or more characters in length.]
Field error in object 'passwordChangeForm' on field 'newPassword': rejected value [**]; codes [Password.passwordChangeForm.newPassword,Password.newPassword,Password.java.lang.String,Password]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [passwordChangeForm.newPassword,newPassword]; arguments []; default message [newPassword],true]; default message [must contain 1 or more digit characters.]
Field error in object 'passwordChangeForm' on field 'confirmPassword': rejected value [***]; codes [com.example.validation.Confirm.message.passwordChangeForm.confirmPassword,com.example.validation.Confirm.message.confirmPassword,com.example.validation.Confirm.message.java.lang.String,com.example.validation.Confirm.message]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [passwordChangeForm.confirmPassword,confirmPassword]; arguments []; default message [confirmPassword],org.springframework.validation.beanvalidation.SpringValidatorAdapter$ResolvableAttribute@6d154140,PROPERTY,EQUAL,false,org.springframework.validation.beanvalidation.SpringValidatorAdapter$ResolvableAttribute@41678dbe]; default message [must same value with "newPassword"]
Field error in object 'passwordChangeForm' on field 'newPassword': rejected value [**]; codes [Password.passwordChangeForm.newPassword,Password.newPassword,Password.java.lang.String,Password]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [passwordChangeForm.newPassword,newPassword]; arguments []; default message [newPassword],true]; default message [must contain 1 or more special characters.]
Field error in object 'passwordChangeForm' on field 'newPassword': rejected value [**]; codes [error.passwordChangeForm.newPassword,error.newPassword,error.java.lang.String,error]; arguments []; default message [error!]
```

# まとめ

システムによってはメッセージ(電文？）をログに出力する際に、秘匿情報をマスキングすることが求められるこごがありますが、アプリケーションログも一緒ですね。というお話でした。
本投稿ではマスキングという手法を使った実装例を紹介しましたが、秘匿情報を保護する方法はマスキングだけではないので、システムの特性や要件にあった方法で行うようにしましょう！！