タグ: Sprint Boot

  • Spring Boot + Nginx でセキュリティ対策を大雑把に実施する

    業務でセキュリティ診断を受けました。
    数回の診断を経て、最終的には「指摘なし」との高評価をいただくことができました。
    この記事では、高評価に至るまでに実施した事を残したいと思います。

    なお、お約束ではありますが、セキュリティは要件次第で対策内容が変わるものです。
    以下の内容は、大雑把にセキュリティ対応を行うためのもので、万全を保証するものではありません。


    今回の構成

    • Spring Boot (3.4.1)
    • Spring Security
    • Thymeleaf
    • バニラJavaScript (react やvue などを使わない)
    • MyBatis
    • Nginx

    Spring Boot

    application-prd.yml に以下の設定を入れます。

    YAML
    server:
      servlet:
        session:
          cookie:
            secure: true   # (1)
            name: __Host-SESSION   # (2)

    これらの設定はHTTPS 環境でないと動かないので、application-prd.yml に記載してapplication.yml (-prdなし)に記載しないと良いです。
    こうすることでローカル環境でもSpring Boot に直接アクセスできます。

    (1) Cookie中のセッションID をSecure 属性で保護します

    付けなかった場合は、HTTP で通信を行った時にもcookie が送付されるため、セッションID が漏えいしてしまい、セッションを乗っ取られる可能性があります。
    とても重要な設定です。

    (2) Cookie中のセッションIDをホストオンリーとします

    セッションIDの名前がデフォルトのJSESSIONID から変更されます。
    __Host- が先頭につくことでサブドメインからのCookie 注入を防いでくれます。
    こちらを設定した場合、Cookie にドメインを指定できなくなります。(する必要がない)


    Spring Security

    以下の設定を入れます。

    Java
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.headers((headers) -> headers
            .contentSecurityPolicy(config -> config
                .policyDirectives("default-src 'self'")
            ) // (3)
        ).csrf(csrf -> csrf
            .csrfTokenRepository(new HttpSessionCsrfTokenRepository())
        ); // (4)
        return http.build();
    }

    (3) Content security policy (CSP)の適用

    CSP を適用することで、ブラウザに意図しない外部サイトのスクリプト実行を制限させます。
    クロスサイトスクリプティング対策として有用です。
    Nginx で設定することも可能ですが、開発時にSpring Boot へ直接アクセスすることがあるので、Spring Security で設定した方が良いと思います。加えて、nonce という例外を書く機能が使えるようになるメリットがありますが、私の場合は最後まで使うことはありませんでした。

    設定した場合に、開発上の注意点が2つあります。

    ひとつは、JS、CSS、画像が外部から読み込めなくなることです。
    CDN など外部から読み込みが必要な場合は、例外の設定が必要になります。
    私の場合、Google recaptcha が必要だったため、以下のような例外設定を追記しました。

    default-src 'self’;
    script-src 'self' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;
    frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;
    connect-src 'self' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;

    もうひとつは、onclick= やstyle= などのインライン属性が使えなくなります。
    こちらはフロント側の開発に大きく影響します。

    onclick についてはイベント形式に書き直します。

    ❌ CSP で制限される書き方

    HTML
    <span onclick="alert('クリックされた!')">クリック可能な文字列</span>

    ✅ CSP 対応済みの書き方

    HTML
    <span data-action="alert">クリック可能な文字列</span>
    JavaScript
    document.querySelectorAll('[data-action="alert"]').forEach(btn => {
      btn.addEventListener('click', () => {
        alert('クリックされた!')
      })
    })

    style= についてはCSS に必要なクラスを作成して対応します。

    ❌ CSP で制限される書き方

    HTML
    <span style="display:none">非表示</span>

    ✅ CSP 対応済みの書き方

    HTML
    <span class="app-display-none">クリック可能な文字列</span>
    CSS
    .app-display-none {
    	display: none;
    }

    当初私は style が使えないことを面倒だと感じましたが、外観をCSS で全て管理できるので綺麗な書き方になると考え直しました。

    (4) CSRF 対策

    攻撃者がXSS などで任意のJavaScript を実行できる場合に、被害者のログイン済みサイトから送金操作などのデータ操作を行うことを防ぎます。
    この設定を入れると、form タグにトークンが自動的に追加されます。

    HTML
    <form action="/action" method="post"> 
      <input type="hidden" name="_csrf" value="...長いトークン...">
      ...
    </form>

    2行目をSpring Security が自動的に挿入します。

    同時に、Spring Securityがトークンの検証を行うため、意図しないページからのPOST アクセスを防ぐことができます。
    注意点として、GETリクエストには適用されないことが挙げられます。
    HTTP の原則に従って、GET でデータ操作を行わないというルールを守る必要があります。

    バニラJavaScript(フレームワークを使わないJavaScript のこと)でサーバへリクエストを投げる(fetch)場合は、このトークンを付与することで投げることができます。
    これは、攻撃者も同じことを行うことが可能なのですが、前述のCSP 対応と後述のThymeleaf によるXSS 対策で防ぐことになります。

    今回の構成から外れますが、SPA の場合は、cookie を使った対策をフレームワークで行います。
    私が経験のあるAngular について、リンクを置いておきます。


    Nginx

    以下の設定を入れます。

    # (5)
    server_tokens off;
    # (6)
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    # (7)
    add_header Referrer-Policy "same-origin, origin-when-cross-origin" always;

    (5) Nginx のバージョンを表示しない

    デフォルトではエラー画面やレスポンスヘッダーにNginx のバージョンが記載されます。
    万一、使っているNginx のバージョンに脆弱性が見つかった場合、バージョン番号が攻撃者に有用な情報となってしまいます。

    (6) Strict-Transport-Security ヘッダを設定

    HTTPS を使っており、HTTP を使わないことをブラウザに覚えさせる設定です。
    一度このヘッダーを受信したブラウザは、タイムアウトするまで(2年間)常にHTTPS でアクセスします。

    (7) Referrer にパスを入れない

    パスに大切な情報が含まれると情報漏洩につながる恐れがあるので、念の為外部サイトにパスを送付しない設定を入れます。

    origin-when-cross-origin を指定すると、同一オリジンにはReferrer を送るが、クロスオリジンにパスを送付しなくなります。この機能は一部のブラウザで未対応らしです。
    same-origin を指定すると、外部サイトにReferrer を送らなくなります。origin-when-cross-origin 未対応のブラウザはこちらが有効になります。


    Thymeleaf

    クロスサイトスクリプティング(XSS)対策として、文字列を代入するときに、th:text の使用を徹底します。

    ✅ 安全な書き方

    HTML
    <span th:text="${name}">ユーザ名</span>

    th:utext を使うとHTML を代入することができますが、問題を起こす可能性が上がるので極力使いません。
    要件次第ではありますが、私が携わったプロジェクトでは一切使わずに済みました。

    ❌ 問題の可能性が上がる書き方

    HTML
    <span th:utext="${html}">動的HTML</span>

    その他のフレームワークを使っている場合でも、似たような機能があるはずです。


    MyBatis

    SQL インジェクション対策として、基本的には$ を使わず# を使うように徹底します。
    しかし、order by だけは # で直接指定することができません。
    代わりに<choose>を使います。

    ✅ 安全な書き方

    SQL
    SELECT
      id,
      name,
      email,
      created_at
    FROM users
    <choose>
      <when test="orderBy == 'name'">
        ORDER BY name
      </when>
      <when test="orderBy == 'createdAt'">
        ORDER BY created_at
      </when>
      <otherwise>
        ORDER BY id
      </otherwise>
    </choose>

    くれぐれもユーザが指定する値を直接$ で渡すことは避けてください。

    ❌ 問題の可能性が上がる書き方

    SQL
    SELECT ... FROM users ORDER BY ${orderBy}

    その他

    IPA の「安全なウェブサイトの作り方」を見ると、上で触れていない問題があります。

    触れてはいませんが、構成上問題になりにくいものと、私の場合要件にないため問題にならないものがありましたので、分類しておきます。

    上で触れたもの
    1 SQLインジェクション
    4 セッション管理の不備
    5 クロスサイト・スクリプティング
    6 CSRF(クロスサイト・リクエスト・フォージェリ)

    構成上問題になりにくいもの
    9 クリックジャッキング (Spring Security 使用のため)
    10 バッファオーバーフロー (Java 使用のため)

    要件にないので問題にならなかったもの
    2 OSコマンド・インジェクション
    3 パス名パラメータの未チェック/ディレクトリ・トラバーサル
    7 HTTPヘッダ・インジェクション
    8 メールヘッダ・インジェクション

    こちらは今回の記事の対象外とさせてください。
    11 アクセス制御や認可制御の欠落


    感想

    脆弱性診断で高評価を得られたことで、今回の記事を書く動機が湧きました。

    勝因は、自前でセキュリティ対策のコードを書かずに、Spring、Nginx、Mybatis といった巨人に委ねた点だと考えています。

    実装にあたって特に面倒だと感じたのは CSP、Thymeleaf、Mybatis のところでした。ですが、一度正しく設定して、雛形をチーム内で共有するところまでたどり着いた後は、スムーズに開発できたと思っています。チーム内で不満の声は上がらなかったです。

    くどいですが、これらの対策を行えば万全というものではなく、何もしないよりこれらをやっておいた方が無難というものになります。
    抜け道を作ることや設定を緩めることは可能ですが、その際は要件と設計とセキュリティをよく検討の上実施ください。

    【広告】

  • Spring Boot + Spring Security + Keycloak でJWT の公開鍵を更新してみる

    私は、JWT 署名に使われる公開鍵を変更した場合、リソースサーバで新しい公開鍵を取得する作業が必要だと考えていました。
    しかし、結論から言うとこれは間違いでした。Spring Boot + Spring Security を使用している場合は何もせずに自動対応されます。

    本当にそうなのか、少し不安に思い検証してみました。

    Keycloak で公開鍵の追加

    Keycloak に公開鍵を追加して試してみます。

    Realm settings > Keys
    ここで鍵の一覧を確認できます。JWT の署名で必要なのはRS256 になります。既存の鍵が1つ存在します。

    ※画像は Keycloak バージョン 26.1.4 のものです。以下同様です。

    公開鍵を追加する場合、Add providers > Add provider をクリックします。

    鍵の種類を選ぶ画面が出ますので、rsa-generated を選択します。

    既存の鍵と区別するためにName をrsa-generated-test とします。

    一覧画面で追加されていることが確認できます。

    この状態で、JWKS エンドポイントをブラウザで表示してみます。

    https://<KEYCLOAK_HOST>/realms/<REALM_NAME>/protocol/openid-connect/certs

    表示されたJSON をchrome console で解析させると、kid が8wY… から始まる鍵が公開されていることが分かります。

    古い鍵のprovider のEnabled をoff に変更すると、JWKS エンドポイントからも削除されます。

    Spring Boot で検証

    以下の順序で検証してみました。

    • Spring boot を起動
    • Keycloak に新しい公開鍵の追加して、古い鍵を無効化する
    • 新しい鍵で著名されたJWT をSpring boot に送信

    結果、Spring boot を再起動することなく、新しい鍵によるJWT 検証が行われました。

    内部の挙動(推測)

    Spring boot はJWK を一定時間キャッシュします。
    JWT ヘッダーに含まれるkid に対応する公開鍵がキャッシュに存在しない場合、JWKS エンドポイントを呼び出して新たな鍵を取得した上で再度検証を行なっていると考えられます。

    便利ですね。

    【広告】

  • Spring Security の認証処理をカスタマイズ

    今までSpring Security の認証処理を理解できていなかった。
    単純なパスワード突合だけなら、UserDetailsService を実装してDB からselect するだけで良いので何とかそれで乗り切っていた。
    しかしながら、現在のプロジェクトで複雑なログイン要件が追加されたため、それだけでは立ち行かなくなってしまった。

    参考までに追加された要件

    • パスワードを連続5回失敗失敗したらアカウントをロックする
    • パスワード変更の有効期限を過ぎたらアカウントをロックする

    いろいろ探しところ、DaoAuthenticationProvider のadditionalAuthenticationChecks() を継承すると非常に柔軟に実装できることが分かった。

    コードはこちら。

    @Component
    @Slf4j
    public class AppAuthenticationProvider extends DaoAuthenticationProvider {
    
      public static final String DEFAULT_LOGIN_ERROR = "ユーザー名またはパスワードが違います";
      public static final String ERROR_TOO_MANY_ATTEMPTS = "連続5回ログインに失敗しました";
      public static final String ERROR_PASSWORD_EXPIRED = "パスワードの有効期限が過ぎています";
    
      private final PlatformTransactionManager transactionManager;
      private final AccountRepository accountRepository;
    
      public AppAuthenticationProvider(PasswordEncoder passwordEncoder, UserDetailsService userDetailsService, PlatformTransactionManager transactionManager, AccountRepository accountRepository) {
        super(passwordEncoder);
        // 親クラスで必要
        setUserDetailsService(userDetailsService);
        this.transactionManager = transactionManager;
        this.accountRepository = accountRepository;
      }
    
      @Override
      protected void additionalAuthenticationChecks(
          UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    
        // 5回以上ログイン失敗している場合はログイン不可
        if (LOGIN_FAIL_MAX_COUNT <= userDetails.getLoginFailCount()) {
          throw new AppLoginException(ERROR_TOO_MANY_ATTEMPTS);
        }
    
        // パスワード変更期限を過ぎていたらログイン不可
        if (userDetails.getPasswordExpiredDate() != null && now().isAfter(account.getPasswordExpiredDate())) {
          throw new AppLoginException(ERROR_PASSWORD_EXPIRED);
        }
    
        // パスワードチェック
        try {
          // 親クラス(DaoAuthenticationProvider)のパスワードチェックを呼び出す
          // パスワード相違時は例外がthrow される
          super.additionalAuthenticationChecks(userDetails, authentication);
          // パスワード一致時はカウンターをリセットする
          new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
            // spring security から呼ばれるのでトランザクションを持っていない
            accountRepository.resetFailCounter(userDetails.getAccountId());
          });
        } catch (AuthenticationException ae) {
          // パスワード相違時は、カウンターをインクリメント
          new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
            // spring security から呼ばれるのでトランザクションを持っていない
            accountRepository.incrementFailCounter(userDetails.getAccountId());
          });
          // すでに4回失敗していたら、エラーメッセージを変更する
          if ((LOGIN_FAIL_MAX_COUNT - 1) <= userDetails.getLoginFailCount()) {
            throw new AppLoginException(ERROR_TOO_MANY_ATTEMPTS);
          } else {
            throw new AppLoginException(DEFAULT_LOGIN_ERROR);
          }
        }
      }
    }

    こちらを@Bean 登録しすると有効になる。

    @Bean
    public AuthenticationManager authenticationManager(AppAuthenticationProvider provider) {
      return new ProviderManager(provider);
    }

    ビルトインの機能を使って、UserDetails のisAccountNonExpired() で似たような実装を行うこともできる。その場合、すでに4回失敗した状態でパスワード相違した時にメッセージを変更することができないと思った。

    throw された例外は、formLogin のfailureHandler に入ってくるので、それを一時的にセッションへ格納し、ログイン画面を表示するコントローラでセッションの値を取得して画面に表示すると良い。
    セッションの値は一度限りで即時削除する。(redirectAttribute と同じだがここでは使えない)

    また、failureHandler を使わず、additionalAuthenticationChecks() でセッションに例外を詰めることもできるが、ユーザ名不正の場合は、additionalAuthenticationChecks() が呼ばれないのでハンドリングできない。(1敗)

    【広告】