OPS

DjangoとReactによる、CSRF対策と注意点

DjangoとReactによる、CSRF対策と注意点

2021.09.24

本記事のポイント

CSRF(クロスサイトリクエストフォージェリ)とは、Webアプリケーションに存在する脆弱性、もしくはその脆弱性を利用した攻撃方法であり、サイバー攻撃の1つです。

本記事では、DjangoとReactを用いたCSRF対策の方法を、注意すべきポイントと合わせてご紹介します。実装に使用するコードも載せておりますので、ぜひご覧ください。



はじめに

Django(ジャンゴ)とは、Pythonで動作するWebアプリケーションフレームワークです。機能的な独立性の高さ故に、メンテナンスが容易で、効率的にアプリケーションを作れることから多くのユーザに使われています。

しかし、Djangoに標準搭載されているテンプレートエンジンではスケールが難しく、しばしばフロントエンドが他のフレームワークに置き換えられる事例が見られます。

Djangoに標準搭載されているCSRF対策機能は、Djangoのテンプレートエンジンを使用することを想定しているため、公式ドキュメントで紹介されている方法ではうまくいきません。

そこで今回は、DjangoとReact(リアクト)をそれぞれバックエンド、フロントエンドとする構成で、CSRF対策を行う方法をご紹介します。

CSRF(クロスサイト・リクエスト・フォージェリ)とは?

CSRF(Cross Site Request Forgery)とは何を指すのでしょう。

RFCCWEに掲載の情報を噛み砕いて解釈すると、攻撃者が、被害者がログイン中のサーバに意図せずリクエストを送信するようなURIを踏ませる攻撃だそうです。

次に、具体例とともに攻撃の仕組みをご紹介します。

CSRFの具体例

CSRFにより不正に銀行からお金が引き落とされる一例を紹介します。

    1. まず、被害者が○○銀行のサイトにアクセスし、ログイン
    2. それにより被害者のブラウザは認証情報を○○銀行のサーバから受け取り、ブラウザに保存
    3. 次に、攻撃者のサイトに訪問し、ページ内のボタンをクリック
    4. ○○銀行に「被害者から攻撃者に▲▲万円振り込む」というリクエストが送られる

CSRFの具体例と、対策の必要性

CSRF対策の必要性

このように、CSRFは被害者の意図しない処理が勝手に実行されてしまう恐ろしい攻撃です。

ほとんどのブラウザにはCROSと言って、サーバ側から明示してオリジンの異なるサイトからのアクセスを許可しない場合は、レスポンスをブラウザ側で受け取れないようにする機能が搭載されています。しかし、それはあくまでブラウザ側で参照できないようにする機能であり、サーバーへのリクエストの到達や処理を防ぐものではありません。そのため、CSRFには無効と言えます。

さらに言えば、モダンなブラウザにはプリフライトリクエストという機能があり、POSTやDELETEといった”safe”でないメソッド (*1) の場合、そのリクエストを送信する前にプリフライトリクエストを送信し、サーバがクロスサイトからのリクエストを許可しているかを確認します。しかし、プリフライトリクエストが発生しない条件というものがあり、抜け道があるため、CSRF対策は必須と言えます。

*1 RFC 7231#section-4.2.1 にて、サーバの状態を変更せずに、情報を読み取ることを目的とするhttpメソッドを”safe”である、と定義しています。具体的には、GET, HEAD, OPTIONS, TRACEが挙げられます。

参考サイト

  • RFC 7231#section-4.2.1
  • RFC 6749#section-10.12
  • CWE-352
  • 安全なウェブサイトの作り方 – 1.6 CSRF(クロスサイト・リクエスト・フォージェリ):IPA 独立行政法人 情報処理推進機構
  • Djangoの標準機能「CSRF Middleware」のしくみ

    続いて、Djangoに標準搭載されているCSRF対策機能の仕組みについてご紹介します。

    Djangoはトークンパターンという方法でCSRF対策をしています。 この方法は、送信フォームごとにCSRF Tokenというランダムな値が埋めこまれたページをクライアントに返し、リクエストをそのトークンをつけて行うことで、正規のページから送信されたものであるとサーバ側で判断することができます。

    ソフトウェアのセキュリティを改善するための国際的な非営利組織であるOWASPも推奨している方法です。なお、CSRF MiddlewareはRefererヘッダのチェックも行いますが、本記事では割愛させて頂きます。

    CSRF Middlewareの 概要

      1. まず、get_token()によりCSRF Tokenを発行します。このとき、CookieにCSRF Tokenがない場合、CookieにもCSRF Tokenをセットされます。

      2. クライアントは発行して得たCSRF Tokenをヘッダにつけてリクエストを送ります(ボディにつけることもできます)。

      3. CSRF MiddlewareでCSRF TokenとCookieのCSRF Tokenを比較して妥当性を確認します。妥当性が確認できなかった場合はステータスコード403(Forbidden)を返します。

    CSRF Tokenの作成方法

    CSRF Tokenを生成する前に、まずsecretというものが生成されます。これは、セッション毎に固定であり、tokenを生成するための元となる乱数です。具体的には、アルファベットと数字から成る32文字のランダムな文字列です。下記のコードのcreate_secret()で生成することができます。

    tokenはそのsecretをランダムマスクして作られる64文字の文字列です。下記のコードのmask_cipher_secret()で実現できます。

    from string import ascii_letters, digits
    from secrets import choice
    
    CSRF_SECRET_LENGTH = 32
    CSRF_ALLOWED_CHARS = string.ascii_letters + string.digits
    
    
    def create_secret()
        return ''.join(choice(CSRF_ALLOWED) for _ in range(CSRF_SECRET_LENGTH))
    
    def mask_cipher_secret(secret):
        mask   = _create_secret()
        chars  = CSRF_ALLOWED_CHARS
        pairs  = zip((chars.index(x) for x in secret), (chars.index(x) for x in mask))
        cipher = ''.join(chars[(x+y)%len(chars) for x,y in paris])
    
        return mask + cipher
    

    tokenの妥当性チェック

    secretが同じであっても生成する度にtokenは異なる値をとるため、ヘッダのCSRF TokenとCookieのCSRF Tokenは異なる値をとります。

    しかし、それぞれの値をランダムマスクと逆の処理をすることでそれぞれのsecretを算出することができます。そして、それぞれのsecretが同じ値であることをみることで妥当性を確認することができます。

    参考サイト

  • Cross-Site Request Forgery Prevention – OWASP Cheat Sheet Series
  • Github – django/middleware/csrf.py at 3.2.7 · django/django
  • Cross Site Request Forgery protection | Django documentation | Django
  • DjangoとReactによるCSRF対策の実装

    それでは、実際にDjangoとReactでCSRF対策が施された簡単なアプリを作ります。

    環境

    Python 3.9.5
    Shell bash(zsh)
    Django 3.2.7
    React 17.0.2

    準備

    バックエンド(Django)

    まず、ディレクトリを作成し、必要なパッケージをインストールします。必要に応じて仮想環境を作成します。

    # ディレクトリの作成
    mkdir backend && cd backend
    
    # 仮想環境の作成・有効化
    python -m venv .venv && source .venv/bin/activate
    
    # パッケージのインストール
    pip install django
    

    次に、Djangoアプリを作成します。

    # アプリの作成
    django-admin startproject csrfprotect .
    
    # modelの適用
    ./manage.py migrate
    
    # viewの作成
    touch csrfprotect/views.py
    

    上記の操作を終えると次のようなファイル構造になります。

    backend/
    ├── db.sqlite3
    ├── manage.py
    └── csrfprotect/
        ├── __init__.py
        ├── asgi.py
        ├── settings.py
        ├── urls.py
        ├── views.py
        └── wsgi.py
    
    フロントエンド(React)

    まず、バックエンド同様にディレクトリを作成し、アプリを作成します。

    # ディレクトリの作成
    mkdir frontend && cd frontend
    
    # アプリの作成
    npx create-react-app .
    

    上記の操作を終えると次のようなファイル構造になります。

    frontend/
    ├── README.md
    ├── node_modules/
    ├── package.json
    ├── public/
    └── src/
        ├── App.css
        ├── App.js
        ├── App.test.js
        ├── index.css
        ├── index.js
        ├── logo.svg
        ├── reportWebVitals.js
        └── setupTests.js
    

    実装

    バックエンド(Django)

    今回は、ローカルホスト、且つ別ポートで各サーバを動作させるため、クロスドメイン制約を無効にします。 まず、必要なパッケージをインストールします。

    pip install django-cros-headers
    

    設定ファイルの特定部分を以下のように書き換えます。

    # backend/csrfprotect/settings.py
    
    INSTALLED_APPS = [
        ...,
        'corsheaders',
        ...,
    ]
    
    MIDDLEWARE = [
        ...,
        ‘corsheaders.middleware.CorsMiddleware’,
        ‘django.middleware.common.CommonMiddleware’,
        ...,
    ]
    
    CORS_ALLOW_CREDENTIALS = True
    CORS_ORIGIN_WHITELIST = ['http://localhost:3000']
    

    次に、CSRF対策に関する部分を実装します。
    Djangoでは下記の通り、CSRF対策のミドルウェアがデフォルトで有効にされています。

    # backend/csrfprotect/settings.py
    
    MIDDLEWARE = [
        ...,
        ‘django.middleware.csrf.CsrfViewMiddleware’,
        ...,
    ]
    

    次に、APIを作成します。
    CSRF Tokenを取得するためのCsrfViewと、CSRFミドルウェアを通過したことを確認するためのPingViewを実装します。

    # backend/csrfprotect/views.py
    
    from django.http import JsonResponse
    from django.middleware.csrf import get_token
    
    
    def CsrfView(request):
        return JsonResponse({'token': get_token(request)})
    
    def PingView(request):
        return JsonResponse({'result': True})
    

    アクセスするためのパスを作成します。

    # backend/csrfprotect/url.py
    
    from django.contrib import admin
    from django.urls import path
    
    from project.views import CsrfView, PingView
    
    
    urlpatterns = [
        path('admin/', admin.site.urls),
        path('csrf/', CsrfView),
        path('ping/', PingView),
    ]
    
    フロントエンド(React)

    リクエストを送るたびに、CSRF Tokenを取得し、そのトークンをヘッダーにつける、という方法で実装を行います。

    # frontend/src/App.js
    
    import {useState} from 'react';
    
    const API_HOST = 'http://localhost:8000';
    const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS', 'TRACE']; // RFC7231
    
    function App() {
      const [csrfFlag, setCsrfFlag] = useState(false);
      const [pingResult, setPingResult] = useState('--');
    
      const api = (path, method) => {
        const isUnsafe = !SAFE_METHODS.includes(method);
    
        return new Promise(async (res, rej) => {
          try { 
            const response = await fetch(`${API_HOST}${path}`, {
              method,
              headers: {
                ...(csrfFlag && isUnsafe &&
                  {'X-CSRFToken' : (await fetchCsrfToken()).token}
                )
              },
              credentials: 'include'
            });
    
            if (response.status !== 200)
              rej(response.statusText);
    
            res(await response.json());
          }
          catch(e) {rej(e)}
        });
      }
    
      const fetchCsrfToken = async () => {
        try { 
          const response = await fetch(`${API_HOST}/csrf/`, {
            credentials: 'include'
          });
          return response.json();
        }
        catch(e) {
          console.error(e);
        }
      }
    
      const sendPing = async () => {
        try { 
          const {result} = await api(`/ping/`, 'POST');
          setPingResult(result ? 'OK' : 'NG');
        }
        catch(e) {
          setPingResult('NG');
        }
      }
      
      return (
        <div className="App">
          <div> 
            <button type='button' onClick={() => {setCsrfFlag(!csrfFlag)}}>CSRF Toggle</button>
            <span>{csrfFlag ? 'ON' : 'OFF'}</span>
          </div>
          <div className='result'>
            <button type='button' onClick={sendPing}>PING</button>
            <span>{pingResult}</span>
          </div>
        </div>
      );
    }
    
    export default App;
    
    参考サイト
    Making React and Django play well together – the “single page app” model – Fractal Ideas adamchainz/django-cors-headers: Django app for handling the server headers required for Cross-Origin Resource Sharing (CORS)

    テスト

    それでは、実際に起動して確認してみましょう。
    バックエンド、フロントエンドでそれぞれ以下のコマンドで実行します。デフォルトでは各々8000、3000番ポート上で起動します。

    # backend
    ./manager.py runserver # ->  on localhost:8000
    
    # frontend
    yarn start # -> on localhost:3000
    # or
    npm start
    

    画面はこのようになっており、「CSRF Toggle」ボタンでCSRF Tokenをサーバに送信するか否かを選択し、PINGで正常に通信ができるかを確認することができます。(別途スタイルを適用しています)

    「CSRF Toggle」ボタンでCSRF Tokenをサーバに送信するか否かを選択

    CSRF Tokenをヘッダにつけずに送ると、ステータスコード403により、「NG」と表示され、

    CSRF Tokenをヘッダにつけずに送ると、ステータスコード403でNGになる

    トークンをつけると、正常に通信することができ、「OK」と表示することが確認できます。

    CSRF TokenをつけるとOKになる

    注意点

    (1) CSRF Tokenを取得するAPIは、同一ドメインからのリクエストのみ受け付けるようにしましょう。外部からCSRF Tokenが可能になると、対策が無効になります。

    (2) RFC7231#section-4.2.1で定義されている”safe”なhttpメソッド(GET, HEAD, OPTIONS, TRACE)に、サーバのリソースを変更するような機能を持たせないようにしましょう。CSRF Middlewareは”safe”なメソッドに対してチェックを行いません。

    (3) XSS対策は個別に行うようにしましょう。同一ドメインからの攻撃(意図せぬリクエスト)が可能になるため、(1)の対策を行っていても、CSRF Tokenを取得できてしまいます。

    まとめ

    今回は、フロントエンドにReactを用いた、DjangoのCSRF対策の方法と注意点をご紹介しました。Djangoの標準搭載の機能を使うことにより、少ない工数で実装することができました。

    DjangoはCSRFの他にも、XSSやSSLリダイレクト等のミドルウェアも標準搭載しているため、そちらにも目を向けて、アプリケーションのセキュリティ強度の向上を検討してみてはいかがでしょうか?