CSRF対策

REST API一覧の説明時にも紹介しましたが、Nablarchではクロスサイト・リクエスト・フォージェリ(以下CSRF)に対策するための機能が提供されています。
(参考:CSRF | 安全なウェブサイトの作り方

CSRFトークンを発行してリクエスト毎にサーバサイドで保持しているCSRFトークンと突き合わせる方式であり、 CSRFトークンを発行するためのユーティリティとCSRFトークンを検証するためのハンドラが提供されています。 (参考:CSRFトークン検証ハンドラ — Nablarch

ここでは、CSRF対策を実現するために、バックエンドでCSRFトークンを取得するREST APIを提供し、フロントエンドからはREST APIで取得したCSRFトークンを使用するように実装していきます。

バックエンド

example-chatのバックエンドでも同様の実装をしているため、その実装を流用します。

バックエンドではCSRFトークンを取得するREST APIと、それを検証するためのハンドラを実装します。

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

CSRFトークンを取得するREST APIについては、example-chatのcom.example.presentation.restapi.system.CsrfTokenActionクラスで同様の実装をしているため、このクラスを流用します。backend/src/main/java/com/example配下にpresentation/restapi/systemディレクトリを作成しCsrfTokenAction.javaをコピーします。

CSRFトークン検証ハンドラの設定

CSRFトークンを検証するハンドラについては、Nablarchから提供されているため、これもコンポーネント定義に追加します。CSRFトークンを検証するハンドラではセッションを使用するため、セッション変数保存ハンドラ(ここではsessionStoreHandler)より後で実行されるように定義します。

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

<!-- CSRFトークン検証ハンドラ -->
<component name="csrfTokenVerificationHandler" class="nablarch.fw.web.handler.CsrfTokenVerificationHandler" />

<!-- ハンドラキュー構成 -->
<component name="webFrontController" class="nablarch.fw.web.servlet.WebFrontController">
  <property name="handlerQueue">
    <list>
...
        <component-ref name="sessionStoreHandler" />

        <component-ref name="threadContextHandler"/>

        <!-- CSRFトークン検証ハンドラ -->
        <component-ref name="csrfTokenVerificationHandler"/>

        <component-ref name="dbConnectionManagementHandler"/>
...
    </list>
  </property>
</component>

ログインチェックハンドラの修正

CSRFトークンを取得するREST APIは、ログインしていなくてもアクセスできる必要があります。ログインしていなくてもアクセスできるパスは、ユーザー認証の実装時に作成したcom.example.system.nablarch.handler.LoginCheckHandlerクラスのコンストラクタで設定しているため、/api/csrf_tokenのパスを追加します。

backend/src/main/java/com/example/system/nablarch/handler/LoginCheckHandler.java

public LoginCheckHandler() {
    whitePatterns
            .add("/api/signup", HttpMethod.POST)
            .add("/api/login", HttpMethod.POST)
            .add("/api/csrf_token", HttpMethod.GET);
}

バックエンドのテスト

次に、バックエンドのテストを実行してみます。

$ mvn test

すると、更新系の操作をするREST APIのテストが失敗します。これは、CSRFトークン検証ハンドラにより、リクエスト時にCSRFトークンが無ければ、ステータスコードが400 Bad Requestのエラーレスポンスとして返却されるためです。

そのため、テスト時にCSRFトークンを使用するように、テストクラスを修正します。

CSRFトークンを使用するには、先ほど実装したREST APIを使用してCSRFトークンとHTTPヘッダ属性名を取得し、リクエスト時のHTTPヘッダに設定する必要があります。

まず、テストクラスに次のようなprivateメソッドを実装します。

backend/src/test/java/com/example/authentication/api/AuthenticationRestApiTest.java

private void attachCsrfToken(RestMockHttpRequest request, ExecutionContext context) {
    HttpResponse response = sendRequest(get("/api/csrf_token"));
    assertStatusCode("CSRFトークンの取得", HttpResponse.Status.OK, response);

    String json = response.getBodyString();
    String name = JsonPath.read(json, "$.csrfTokenHeaderName");
    String value = JsonPath.read(json, "$.csrfTokenValue");

    request.setHeader(name, value);

    WebConfig webConfig = WebConfigFinder.getWebConfig();
    String storedVarName = webConfig.getCsrfTokenSessionStoredVarName();
    String storeName = webConfig.getCsrfTokenSavedStoreName();
    if (storeName != null) {
        SessionUtil.put(context, storedVarName, value, storeName);
    } else {
        SessionUtil.put(context, storedVarName, value);
    }
}

このメソッドでは以下の実装をしています。

  • CSRFトークンを取得するREST APIを呼び出す
  • リクエストヘッダーにCSRFトークンをセットする
  • セッションストアにトークンをセットする

セッションストアにも保存するのは、サーバサイドで比較元として使用するためです。 ExecutionContextを使用したセッションストアへの設定については、ユーザー認証でのテストクラスへの実装と同様に実装します。

各REST APIのテストで、REST APIにアクセスする前にこのattachCsrfTokenメソッドを呼び出すように修正します。

例えばサインアップのテストであれば、次のように修正します。

@Test
public void RESTAPIでサインアップできる() throws Exception {
    ExecutionContext executionContext = new ExecutionContext();
    RestMockHttpRequest request = post("/api/signup")
            .setHeader("Content-Type", MediaType.APPLICATION_JSON)
            .setBody(Map.of(
                    "userName", "signup-test",
                    "password", "pass"));
    attachCsrfToken(request, executionContext);
    HttpResponse response = sendRequestWithContext(request, executionContext);
...

例えばログアウトのようにユーザーIDを設定しているテストであれば、次のように修正します。

@Test
public void RESTAPIでログアウトできる() throws Exception {
    ExecutionContext executionContext = new ExecutionContext();
    SessionUtil.put(executionContext, "user.id", "1010");

    RestMockHttpRequest request = post("/api/logout");
    attachCsrfToken(request, executionContext);
    HttpResponse response = sendRequestWithContext(request, executionContext);
...

各REST APIのテストで同様の実装をすると、テストが成功します。

これでバックエンドの実装は完了です。

フロントエンド

続いて、フロントエンドを実装します。

REST APIクライアントへのCSRFトークン組込

生成したREST APIクライアントのラッパーを実装したBackendService.tsに、CSRFトークンを使用するための実装を追加します。

src/backend/BackendService.ts

import {
  ...
  FetchParams,
  HTTPMethod,
  RequestContext,
} from './generated-rest-client';

...

class CsrfTokenAttachment implements Middleware {
  private readonly targetMethod: ReadonlyArray<HTTPMethod> = ['POST', 'PUT', 'DELETE', 'PATCH'];
  private headerName = '';
  private tokenValue = '';

  setCsrfToken(headerName: string, tokenValue: string) {
    console.log('setCsrfToken:', headerName, tokenValue);
    this.headerName = headerName;
    this.tokenValue = tokenValue;
  }

  pre = async (context: RequestContext): Promise<FetchParams | void> => {
    if (!this.headerName || !this.targetMethod.includes(context.init.method as HTTPMethod)) {
      return;
    }
    console.log('attach csrf token:', this.headerName, this.tokenValue);
    return {
      url: context.url,
      init: {
        ...context.init,
        headers: {
          ...context.init.headers,
          [this.headerName]: this.tokenValue,
        },
      },
    };
  };
}

const csrfTokenAttachment = new CsrfTokenAttachment();

const configuration = new Configuration({
  middleware: [csrfTokenAttachment, corsHandler, requestLogger],
});

...

const refreshCsrfToken = async () => {
  const response = await usersApi.getCsrfToken();
  csrfTokenAttachment.setCsrfToken(response.csrfTokenHeaderName, response.csrfTokenValue);
};

export const BackendService = {
...
  refreshCsrfToken,
};

すでに説明したとおり、生成したREST APIのクライアントコードでは、Middlewareと呼ばれる部品を作成することで、リクエストやレスポンスに対する共通的な処理を実装することができます。

まず、CSRFトークンをヘッダに設定するCsrfTokenAttachmentクラスを、Middlewareとして実装します。(CSRFトークンを状態として保持しやすくするよう、クラスとして実装します)

Middlewareに追加するため、CsrfTokenAttachmentクラスのインスタンスを生成し、middlewareプロパティに追加します。

次に、CSRFトークン取得のREST APIを呼び出してCsrfTokenAttachmentに設定するrefreshCsrfToken関数を実装します。 CSRFトークンはセッションストアに紐付いており、セッションが切り替わるタイミングでCSRFトークンも変更されます。 そのため、ログインやログアウト等のセッション切替タイミングで、この関数を実行してCSRFトークンを最新化します。

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

この関数を実行してCSRFトークンを最新化するため、共通の部品として実装します。
具体的には、CSRFトークンを最新化する部品を作成し、それをlayout.tsxで呼び出します。

srcディレクトリ配下にinitializerディレクトリを作成し、その中にAppInitializer.tsxを作成して、下記のコードを実装します。

src/initializer/AppInitializer.tsx

'use client';
import React, {useEffect, useState} from 'react';
import {BackendService} from '../backend/BackendService';

const AppInitializer: React.FC<{children: React.ReactNode}> = ({children}) => {
  const [initialized, setInitialized] = useState(false);

  useEffect(() => {
    BackendService.refreshCsrfToken().finally(() => setInitialized(true));
  }, []);

  if (!initialized) {
    return <React.Fragment />;
  }

  return children;
};

export default AppInitializer;

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

return文でchildrenを返すことにより、AppInitializerでラップするコンポーネントをレンダリングする実装になっています。 作成したAppInitializerを共通の部品として使用するために、layout.tsxで呼び出します。

src/app/layout.tsx

...
import AppInitializer from '../initializer/AppInitializer';

...
  return (
    <html lang='en'>
      <body>
        <AppInitializer>
          <UserContextProvider>
            <NavigationHeader />
            {children}
          </UserContextProvider>
        </AppInitializer>
      </body>
    </html>
  );
}

この実装により、セッション切替のタイミングでCSRFトークンを最新化できるようになりました。

ログイン・ログアウト時のCSRFトークン設定

ログイン、ログアウト時に、セッションを破棄して新しく開始するようにします。 UserContextにあるloginlogout関数内で、先ほど作成したrefreshCsrfToken関数を実行するように実装します。

src/contexts/UserContext.tsx

...

export const UserContextProvider: React.FC<Props> = ({children}) => {
  ...

  const contextValue: ContextValueType = {
    ...
    login: async (userName, password) => {
      try {
        await BackendService.login(userName, password);
        await BackendService.refreshCsrfToken();
        setUserName(userName);
        ...
    },
    logout: async () => {
      await BackendService.logout();
      await BackendService.refreshCsrfToken();
      setUserName('');
    },
    ...

これで、フロントエンドの実装は完了です。

動作確認

いままでと同じように、フロントエンドアプリとバックエンドアプリを起動し、動作を確認します。

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

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

>> GET http://localhost:9080/api/csrf_token 
Object
body: undefined
credentials: "include"
headers: {}
method: "GET"
mode: "cors"
[[Prototype]]: Object
<< 200 http://localhost:9080/api/csrf_token 
Response
body: ReadableStream
bodyUsed: false
headers: Headers {}
ok: true
redirected: false
status: 200
statusText: "OK"
type: "cors"
url: "http://localhost:9080/api/csrf_token"
[[Prototype]]: Response
setCsrfToken: X-CSRF-TOKEN fe709dd4-ed6a-4436-ab6d-63dce1291b05

これで、CSRF対策の実装は完了です。

results matching ""

    No results matching ""