はじめに
なんだか名前だけ聞いたことあるCSRFにつて調べたことの概念とGoでの実装例を記事にしたものです。
CSRFとは?
CSRF(Cross-Site Request Forgery)は、悪意のある第三者のWebサイトから、ユーザーが意図しないリクエストを送信させる攻撃手法です。
例として以下のような攻撃シナリオが挙げられます。
- ユーザーが正規のWebサイトにログインしている
- ログインによるセッションを保持した状態で、同じブラウザを使用して悪意のあるWebサイトにアクセスする
- 悪意のあるサイトに埋め込まれたスクリプトやフォームにより、ユーザーの意図しないリクエストなどが実行される
この攻撃が成功する理由は、ブラウザが自動的にCookieを送信する仕様を悪用しているためです。ユーザーが認証済みの状態であれば、攻撃者は正規のセッションを利用してリクエストを送信できてしまいます。
CSRFトークンとは?
CSRFトークンは、この攻撃を防ぐための対策の一つです。このトークンの手段としては以下のような例が挙げられます。
まず、サーバーサイドで予測不可能な文字列を生成します。それをセッションとしてクライアントに送り、フォームのhidden項目やHTTPヘッダーとして次回のPOSTなどのリクエストと一緒に送信させます。最後に、送信されたリクエストをサーバーサイドで検証し、そのトークンが正しければ内部動作を走らせるようにします。
CSRFトークンによる保護の仕組みは以下の通りです:
- サーバーは正規のフォームを生成する際に、ランダムなトークンを生成し埋め込む
- クライアントからリクエストを受け取った際に、トークンの存在と値を検証する
- トークンが不正な場合はリクエストを拒否する
この仕組みにより、攻撃者は正規のトークンを知ることができないため、不正なリクエストを送信できなくなります。
Goで実装してみる
実際にGoでCSRFトークンのコードを作成すると以下のようなものになります。
1. 必要なパッケージのインポート
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トークン生成関数
func generateToken() string {
b := make([]byte, 32)
rand.Read(b)
return base64.StdEncoding.EncodeToString(b)
}
generateToken()
はランダムな文字列を作成する関数です。この生成した文字列をトークンとして使用します。
3. セッション作成とCSRFトークンの設定
// セッション生成と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ミドルウェア
// 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リクエストを処理するハンドラ
// 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関数の説明
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
で起動
実装コード完成系
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))
}
SECRET_KEY=secret-key
secret-key
は、以下のコマンドで簡単に作成できます。
openssl rand -base64 32
このコードは基本的な実装例であり、実際のアプリケーションでは、以下の点に注意する必要があります:
- トークンの有効期限設定
- 適切なエラーハンドリング
- HTTPSの使用
動作テスト
エンドポイント
# POST
http://localhost:8080/submit
# GET
http://localhost:8080/create-session
一旦はトークンがまだ生成されていない状態でPOSTをしてみた結果が以下のものになります。CSRF
トークンがないのでエラーが出ていますね。
実際にトークンを取得します。
レスポンスで生成されたトークンが返ってきました。
返ってきたトークンをHeader
にKey
をX-CSRF-Token
、Value
をトークンとして追加します。
この状態でPOSTのリクエストを送ってみてください。
{
"message": "アドベントカレンダー"
}
参考