CSRFトークンとは？

# はじめに
なんだか名前だけ聞いたことあるCSRFにつて調べたことの概念とGoでの実装例を記事にしたものです。

# CSRFとは？
CSRF（Cross-Site Request Forgery）は、悪意のある第三者のWebサイトから、ユーザーが意図しないリクエストを送信させる攻撃手法です。
例として以下のような攻撃シナリオが挙げられます。

1. ユーザーが正規のWebサイトにログインしている
2. ログインによるセッションを保持した状態で、同じブラウザを使用して悪意のあるWebサイトにアクセスする
3. 悪意のあるサイトに埋め込まれたスクリプトやフォームにより、ユーザーの意図しないリクエストなどが実行される

この攻撃が成功する理由は、ブラウザが自動的にCookieを送信する仕様を悪用しているためです。ユーザーが認証済みの状態であれば、攻撃者は正規のセッションを利用してリクエストを送信できてしまいます。

# CSRFトークンとは？
CSRFトークンは、この攻撃を防ぐための対策の一つです。このトークンの手段としては以下のような例が挙げられます。
まず、サーバーサイドで予測不可能な文字列を生成します。それをセッションとしてクライアントに送り、フォームのhidden項目やHTTPヘッダーとして次回のPOSTなどのリクエストと一緒に送信させます。最後に、送信されたリクエストをサーバーサイドで検証し、そのトークンが正しければ内部動作を走らせるようにします。

CSRFトークンによる保護の仕組みは以下の通りです：

1. サーバーは正規のフォームを生成する際に、ランダムなトークンを生成し埋め込む
2. クライアントからリクエストを受け取った際に、トークンの存在と値を検証する
3. トークンが不正な場合はリクエストを拒否する

この仕組みにより、攻撃者は正規のトークンを知ることができないため、不正なリクエストを送信できなくなります。


# Goで実装してみる
実際にGoでCSRFトークンのコードを作成すると以下のようなものになります。
### 1. 必要なパッケージのインポート
```go
import (
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"github.com/gorilla/sessions"
	"github.com/joho/godotenv"
	"log"
	"net/http"
)
```
#### 一部パッケージの説明

- `crypto/rand`: ランダムなデータを生成するために使用します。CSRFトークン生成に利用
- `encoding/base64`: バイナリデータを Base64 エンコードするために使用します。トークンを安全に文字列として扱うために利用
- `github.com/gorilla/sessions`: セッション管理のためのパッケージ。セッションストアを使ってユーザーの状態を管理します

### 2.  CSRFトークン生成関数
```go
 func generateToken() string {
	b := make([]byte, 32)
	rand.Read(b)
	return base64.StdEncoding.EncodeToString(b)
}
```
`generateToken()`はランダムな文字列を作成する関数です。この生成した文字列をトークンとして使用します。

### 3. セッション作成とCSRFトークンの設定
```go
// セッション生成とCSRFトークンの設定
func createSession(w http.ResponseWriter, r *http.Request) {
	session, err := store.Get(r, "csrf-session")
	if err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	token := generateToken()
	session.Values["csrf_token"] = token

	if err := session.Save(r, w); err != nil {
		fmt.Fprintf(w, "error: Failed to save session: %s", err)
		return
	}

	// トークンを返す（ここでは単に通知として返す）
	fmt.Fprintf(w, "Session created. CSRF Token: %s", token)
}
```
`createSession` は、セッションを作成し、`CSRFトークン`を生成してセッションに保存し、トークンをレスポンスとして返す関数です。このレスポンスとして返された関数はクライアント側でヘッダーに保存されることを想定しています。
流れとしては以下のようになります。

- `store.Get(r, "csrf-session")` でセッションを取得
- `generateToken()` で新しいトークンを生成
- セッションにトークンを格納し、`session.Save(r, w)` でセッションデータを保存
- 最後に、生成された `CSRF` トークンをレスポンスとして返す

### 4. CSRFミドルウェア
```go
// CSRFトークンを検証するミドルウェア
func csrfMiddleware(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		session, err := store.Get(r, "csrf-session")
		if err != nil {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			return
		}

		if r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" {
			// ヘッダーからCSRFトークンを取得
			formToken := r.Header.Get("X-CSRF-Token")

			// セッションに保存されているトークンを取得
			sessionToken, ok := session.Values["csrf_token"].(string)

			if !ok || formToken == "" || formToken != sessionToken {
				http.Error(w, "Invalid CSRF Token", http.StatusForbidden)
				return
			}
		}

		next(w, r)
	}
}
```

`csrfMiddleware` は、CSRF攻撃を防ぐためのミドルウェアです。
このミドルウェアは以下のようなものを実装しています。
- `store.Get(r, "csrf-session")` で、リクエストに関連するセッションを取得
- `r.Header.Get("X-CSRF-Token")` で、リクエストヘッダーから送られた `CSRF トークン`を取得
- セッション内のトークンとヘッダーのトークンを比較し、異なる場合はリクエストを拒否します（`HTTP 403 Forbidden` を返します）
- トークンが正しければ、リクエスト処理を次に渡す

### 5. JSONリクエストを処理するハンドラ
```go
// JSONリクエストを処理するハンドラ
func submitForm(w http.ResponseWriter, r *http.Request) {
	var requestData map[string]string
	if err := json.NewDecoder(r.Body).Decode(&requestData); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	message := requestData["message"]
	fmt.Fprintf(w, "Received message: %s", message)
}
```

`submitForm`は、`POST` リクエストで送られた `JSON` データを処理します。
`json.NewDecoder(r.Body).Decode(&requestData)` でリクエストボディを `JSON` としてデコードし、`message` フィールドを取り出して表示します。

### 6. main関数の説明
```go
func main() {
	// .env ファイルをロード
	if err := godotenv.Load(); err != nil {
		log.Fatal("Error loading .env file")
	}

	store = sessions.NewCookieStore([]byte(os.Getenv("SECRET_KEY"))) // セッションストアの作成
	store.Options = &sessions.Options{
		HttpOnly: true,
		Secure:   true, // HTTPS通信のみに送信される
		MaxAge:   600,  // クッキーの有効期限(10分)
		SameSite: http.SameSiteStrictMode,
	}

	http.HandleFunc("/create-session", createSession)
	http.HandleFunc("/submit", csrfMiddleware(submitForm))

	log.Println("Starting server on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
```
`main`関数は以下のようなものを実装しています。
- `.env` ファイルをロードし、環境変数を読み込むために `godotenv.Load()` を呼び出し
- `sessions.NewCookieStore([]byte(os.Getenv("SECRET_KEY")))` でセッションストアを初期化
- トークンを発行するための`http.HandleFunc("/create-session", createSession)` と メッセージをPOSTする`http.HandleFunc("/submit", csrfMiddleware(submitForm))` で、リクエストパスとハンドラを関連付け
- `http.ListenAndServe(":8080", nil)` でサーバーをポート`8080`で起動

## 実装コード完成系
```main.go
package main

import (
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"github.com/gorilla/sessions"
	"github.com/joho/godotenv"
	"log"
	"net/http"
	"os"
)

var store *sessions.CookieStore

// CSRFトークンを生成する関数
func generateToken() string {
	b := make([]byte, 32)
	rand.Read(b)
	return base64.StdEncoding.EncodeToString(b)
}

// CSRFトークンを検証するミドルウェア
func csrfMiddleware(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		session, err := store.Get(r, "csrf-session")
		if err != nil {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			return
		}

		if r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE" {
			// ヘッダーからCSRFトークンを取得
			formToken := r.Header.Get("X-CSRF-Token")

			// セッションに保存されているトークンを取得
			sessionToken, ok := session.Values["csrf_token"].(string)

			if !ok || formToken == "" || formToken != sessionToken {
				http.Error(w, "Invalid CSRF Token", http.StatusForbidden)
				return
			}
		}

		next(w, r)
	}
}

// セッション生成とCSRFトークンの設定
func createSession(w http.ResponseWriter, r *http.Request) {
	session, err := store.Get(r, "csrf-session")
	if err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	token := generateToken()
	session.Values["csrf_token"] = token
	store.Options = &sessions.Options{
		HttpOnly: true, // JavaScriptからアクセスできないようにする
		Secure:   true, // HTTPS通信のみに送信されるようにする
		MaxAge:   3600, // クッキーの有効期限（例：1時間）
	}
	if err := session.Save(r, w); err != nil {
		fmt.Fprintf(w, "error: Failed to save session: %s", err)
		return
	}

	// トークンを返す（ここでは単に通知として返す）
	fmt.Fprintf(w, "Session created. CSRF Token: %s", token)
}

// JSONリクエストを処理するハンドラ
func submitForm(w http.ResponseWriter, r *http.Request) {
	var requestData map[string]string
	if err := json.NewDecoder(r.Body).Decode(&requestData); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	message := requestData["message"]
	fmt.Fprintf(w, "Received message: %s", message)
}

func main() {
	// .env ファイルをロード
	if err := godotenv.Load(); err != nil {
		log.Fatal("Error loading .env file")
	}

	store = sessions.NewCookieStore([]byte(os.Getenv("SECRET_KEY"))) // セッションストアの作成
	store.Options = &sessions.Options{
		HttpOnly: true,
		Secure:   true, // HTTPS通信のみに送信される
		MaxAge:   600,  // クッキーの有効期限(10分)
		SameSite: http.SameSiteStrictMode,
	}

	http.HandleFunc("/create-session", createSession)
	http.HandleFunc("/submit", csrfMiddleware(submitForm))

	log.Println("Starting server on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
```

```.env
SECRET_KEY=secret-key
```

:::note info
`secret-key`は、以下のコマンドで簡単に作成できます。
```
 openssl rand -base64 32
```
:::


:::note warn
このコードは基本的な実装例であり、実際のアプリケーションでは、以下の点に注意する必要があります：

- トークンの有効期限設定
- 適切なエラーハンドリング
- HTTPSの使用
:::

## 動作テスト
エンドポイント
```bash
# POST
http://localhost:8080/submit

# GET
http://localhost:8080/create-session
```
一旦はトークンがまだ生成されていない状態でPOSTをしてみた結果が以下のものになります。`CSRF` トークンがないのでエラーが出ていますね。
![スクリーンショット 2024-12-22 0.04.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/429defef-2fee-211c-1f66-3c0e6fe6a941.png)

実際にトークンを取得します。

![スクリーンショット 2024-12-22 0.07.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/2c57958e-e73e-a90d-857b-ba01fc833fd9.png)
レスポンスで生成されたトークンが返ってきました。
返ってきたトークンを`Header`に`Key`を`X-CSRF-Token`、`Value`をトークンとして追加します。

![スクリーンショット 2024-12-22 0.13.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/d9e3e2ec-b08a-69d1-6fe7-3bb08ed14e5a.png)

この状態でPOSTのリクエストを送ってみてください。
```json
{
    "message": "アドベントカレンダー"
}
```
![スクリーンショット 2024-12-22 0.17.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3759397/fc565ee5-2185-b3fe-c125-2ecdb595e63a.png)

# 参考
https://www.ipa.go.jp/security/vuln/websecurity/csrf.html