はじめに

こんにちは。西日本テクノロジー&イノベーション室の齊藤です。

私の所属する西日本テクノロジー&イノベーション室では、2020年9月末に「SPA + REST API構成のサービス開発リファレンス(以下サービス開発リファレンス)」を公開しました。こちらは、シングルページアプリケーション(以下SPA)とREST APIから構成されるWebアプリケーションを開発する際に活用して頂けるコンテンツになっています。

前回のブログでは、登録したTodoをCSVファイルとしてダウンロードする実装をご紹介しました。
今回はSPA + REST API構成でのCSRF対策について解説します。

  1. サービス開発リファレンスを使ってWebアプリケーションを作成してみよう<導入編>
  2. サービス開発リファレンスを使って1画面作成してみよう
  3. サービス開発リファレンスを使ってREST APIを1つ作成してみよう
  4. サービス開発リファレンスを使ってバリデーションしてみよう
  5. サービス開発リファレンスを使ってファイルダウンロードを実装してみよう
  6. サービス開発リファレンスを使ってCSRF対策をしてみよう(←今回の記事)

    事前準備

    1~5までの記事でご紹介した作業の完成状態をベースに作成しますので、こちらの作業がまだの方は実施してみてください。

    CSRFとは

    「CSRF | 安全なウェブサイトの作り方」から抜粋します。

    ウェブサイトの中には、サービスの提供に際しログイン機能を設けているものがあります。ここで、ログインした利用者からのリクエストについて、その利用者が意図したリクエストであるかどうかを識別する仕組みを持たないウェブサイトは、外部サイトを経由した悪意のあるリクエストを受け入れてしまう場合があります。
    このようなウェブサイトにログインした利用者は、悪意のある人が用意した罠により、利用者が予期しない処理を実行させられてしまう可能性があります。このような問題を「CSRF(Cross-Site Request Forgeries/クロスサイト・リクエスト・フォージェリ)の脆弱性」と呼び、これを悪用した攻撃を、「CSRF攻撃」と呼びます。

    CSRF対策

    CSRFは利用者が意図したリクエストであるかどうかを識別する仕組みを持つことで対策できます。

    その手段としては、バックエンドでランダムな値(以下CSRFトークンと呼びます)を生成し、それをフロントエンドに渡します。 このときバックエンド側ではセッションにCSRFトークンを保存します。
    フロントエンドでは、HTTPリクエストにCSRFトークンを埋め込み、送信します。

    バックエンドではHTTPリクエストに埋め込まれたCSRFトークンと、セッションから取り出したCSRFトークンを突き合わせて、一致すれば利用者が意図したリクエストだとみなして処理を続行します。 CSRFトークンが一致しなければ不正なリクエストだとみなして処理を終了します。

    では実際にSPA + REST API構成でのCSRF対策について、それぞれ実装します。

    バックエンド

    CSRFトークンを取得するREST APIの作成

    Nablarchでは、CSRF対策の機能が提供されています。

    これらを使い、CSRFトークンを返却するREST APIと、それを検証するための仕組みを実現します。

    まずはCSRFトークンを取得するREST APIを作成します。

    backend/src/main/java/com/example/presentation/restapi/system/CsrfTokenAction.java

    package com.example.presentation.restapi.system;
    
    import javax.ws.rs.GET;
    import javax.ws.rs.Path;
    import javax.ws.rs.Produces;
    import javax.ws.rs.core.MediaType;
    
    import nablarch.core.repository.di.config.externalize.annotation.SystemRepositoryComponent;
    
    import nablarch.common.web.csrf.CsrfTokenUtil;
    import nablarch.fw.ExecutionContext;
    
    @SystemRepositoryComponent
    @Path("/csrf_token")
    public class CsrfTokenAction {
    
        @GET
        @Produces(MediaType.APPLICATION_JSON)
        public CsrfTokenResponse get(ExecutionContext context) {
            String csrfToken = CsrfTokenUtil.getCsrfToken(context);
            String headerName = CsrfTokenUtil.getHeaderName();
            String parameterName = CsrfTokenUtil.getParameterName();
            return new CsrfTokenResponse(csrfToken, headerName, parameterName);
        }
    
        public static class CsrfTokenResponse {
    
            public final String csrfTokenValue;
    
            public final String csrfTokenHeaderName;
    
            public final String csrfTokenParameterName;
    
            public CsrfTokenResponse(String csrfTokenValue, String csrfTokenHeaderName, String csrfTokenParameterName) {
                this.csrfTokenValue = csrfTokenValue;
                this.csrfTokenHeaderName = csrfTokenHeaderName;
                this.csrfTokenParameterName = csrfTokenParameterName;
            }
        }
    }

    次に、コンポーネント定義にCSRFトークンを検証するハンドラを追加します。
    設定はすでにあるため、以下コメントアウトを外してください。

    backend/src/main/resources/rest-component-configuration.xml

    <!--  <component name="csrfTokenVerificationHandler" class="nablarch.fw.web.handler.CsrfTokenVerificationHandler" >-->
    <!--    <property name="verificationFailureHandler">-->
    <!--      <component class="com.example.system.nablarch.handler.csrf.CsrfTokenVerificationFailureHandler"/>-->
    <!--    </property>-->
    <!--  </component>-->
    
    ...
    
            <!-- CSRFトークンの発行や検証を行うために追加 -->
    <!--        <component-ref name="csrfTokenVerificationHandler"/>-->

    これで実装は完了です。

    サーバの起動

    ここまで実装できたら、サーバを起動してみましょう。 以下コマンドでまずデータベースを起動します。

    $ docker-compose up -d

    次のようにdocker-compose psコマンドを実行してStateがUpになっていれば起動成功です。

    $ docker-compose ps
    Name                 Command                        State     Ports                     
    -----------------------------------------------------------------------------------------------------------------
    example_postgres_1   docker-entrypoint.sh postgres    Up      0.0.0.0:5432->5432/tcp  
    example_redis_1      docker-entrypoint.sh redis ...   Up      0.0.0.0:6379->6379/tcp

    次に、backendディレクトリで以下コマンドを実行してサーバを起動しましょう。

    $ mvn jetty:run

    動作確認はフロントエンドとあわせて実施します。

    フロントエンド

    次にフロントエンド側を実装します。

    REST APIを呼び出す関数の追加

    さきほど作成したREST APIを呼び出すための関数を、BackendService.tsに追加します。
    また、取得したCSRFトークンの初期設定をするための関数(refreshCsrfToken)も実装します。

    frontend/src/example/backend/BackendService.ts

    const getCsrfToken = async () => {
      Logger.debug('call service of getCsrfToken');
    
      const response = await restClient.get('/api/csrf_token');
      Logger.debug(response);
      if (response.ok) {
        return await response.json();
      }
      throw new Error(`Web API call failed. [ status code: ${response.status} ]`);
    };
    
    function refreshCsrfToken(): Promise<void> {
      Logger.debug('call service of refreshCsrfToken');
      
      return getCsrfToken().then(({ csrfTokenHeaderName, csrfTokenValue}) => {
        Logger.debug('csrfTokenHeaderName:', csrfTokenHeaderName, 'csrfTokenValue:', csrfTokenValue);
        restClient.csrfTokenHeaderName = csrfTokenHeaderName;
        restClient.csrfTokenValue = csrfTokenValue;
      });
    }

    コンポーネントからこれらの関数を呼び出せるように、export defaultに追加します。

    export default {
      getHelloWorld,
      getTodos,
      postTodo,
      createFile,
      downloadFile,
      getCsrfToken,
      refreshCsrfToken,
    };

    アプリ起動時のCSRFトークン設定

    アプリ起動時にCSRFトークンを設定するため、Appコンポーネントで先ほど作成したrefreshCsrfToken関数を実行するようにします。

    frontend/src/App.tsx

    const App = () => {
      Logger.debug('rendering App...');
    
      const [initialized, setInitialized] = useState(false);
      useEffect(() => {
        BackendService.refreshCsrfToken()
        .finally(() => setInitialized(true));
      }, []);
    
      if (!initialized) {
        return (
            <React.Fragment />
        );
      }
    ...

    refreshCsrfToken関数によるCSRFトークン取得が完了するまでは、他のコンポーネントを処理したくないため、それを制御するためのフラグとしてinitializedstateを作成します。
    refreshCsrfTokenの処理が終わるまでは、JSXで空要素を返すようにし、refreshCsrfTokenの処理が完了してinitializedtrueになったタイミングで、今までと同様のコンポーネント処理を実行するようにします。

    動作確認

    ここまで実装できたら、frontendディレクトリで以下コマンドを実行して、フロントエンドアプリを起動しましょう。

    $ npm start

    自動でブラウザが立ち上がり、Top画面が表示されます。

    表示されたら、以下URLのTodo画面を開いてみましょう。

    http://localhost:3000/todo

    CSRF対策の実装は内部的な処理であるため、ページの動作や見た目については、何も影響がありません。

    CSRFトークンの取得や設定時にはログを出力していますので、ブラウザの開発者ツールでコンソールを確認してみましょう。
    次のようなログが出力され、正常に動作していることが確認できます。

    > call service of refreshCsrfToken
    > call service of getCsrfToken
    > Response {type: "cors", url: "http://localhost:9080/api/csrf_token", redirected: false, status: 200, ok: true, …}
    > csrfTokenHeaderName: X-CSRF-TOKEN csrfTokenValue: 688cd7c9-ded8-431f-b1a1-13a95eb94758

    まとめ

    今回は、サービス開発リファレンスを使ってSPA + REST API構成におけるCSRF対策を実現しました。
    次回はサービス開発リファレンスの方式設計ガイドの使い方をご紹介します。


    本コンテンツはクリエイティブコモンズ(Creative Commons) 4.0 の「表示—継承」に準拠しています。