🔒

【CSRF対策】SameSite属性の設定のみで本当に大丈夫? Goで実践

に公開

はじめに

Webアプリケーションのセキュリティ対策において、クロスサイトリクエストフォージェリ(CSRF)は最も一般的な脆弱性の一つです。CSRFは、ユーザーが認証された状態でWebサイトを閲覧している際に、攻撃者が用意した悪意のあるサイトやメールからのリクエストによって、ユーザーの意図しない操作を実行させる攻撃です。

最近のWebブラウザでは、CSRF対策としてCookieのSameSite属性が注目されています。この属性を設定するだけで十分なのか、それとも従来から使われているトークンによる対策も併用すべきなのか、疑問を持つ開発者も多いでしょう。

本記事では、CSRF対策としてのSameSite属性の有効性を検証し、トークン対策との併用の必要性について解説します。さらに、Goでの実装例も交えながら、より堅牢なCSRF対策の方法を紹介します。

CSRF対策の基本とSameSite属性

CSRF攻撃のメカニズム

CSRFは、攻撃者が準備した罠サイトに被害者を誘導し、被害者のブラウザを使って脆弱なサイトに対してリクエストを送信させる攻撃です。この攻撃が成功するための条件は以下の通りです:

  1. 認証状態:被害者が脆弱なサイトに対して認証済みである
  2. Cookie送信:ブラウザが自動的にCookieを送信する
  3. 予測可能なリクエスト:攻撃者がリクエストの内容を予測できる

これらの条件が揃うと、攻撃者は被害者の認証情報を使って、被害者になりすまして操作を実行できます。例えば、パスワード変更、メールアドレス変更、資金の送金などです。

SameSite属性とは

SameSite属性は、Cookie送信の制御を行い、クロスサイトリクエストの際にブラウザがCookieを送信するかどうかを指定するものです。主に3つの値を設定できます:

  1. Strict:最も厳格な設定。同一サイトからのリクエストのみCookieを送信
  2. Lax:トップレベルナビゲーション(リンクのクリックなど)とGETリクエストのみCookieを送信
  3. None:クロスサイトリクエストでもCookieを送信(Secure属性との併用が必須)

Chrome 80(2020年2月)以降、デフォルト値はLaxに設定されています。これにより、多くのCSRF攻撃がデフォルトで防止されるようになりました。

SameSite属性のみで十分?

SameSite=Strictを設定すれば確かに強力なCSRF対策になりますが、以下の課題もあります:

  1. ブラウザの互換性:古いブラウザではSameSite属性がサポートされていない
  2. UX上の制約Strict設定では、外部サイトからの正当な遷移でもCookieが送信されないため、ユーザー体験が損なわれる場合がある
  3. 特定の攻撃パターンに対する脆弱性:一部の巧妙な攻撃では、SameSite属性を迂回できる可能性がある

これらの理由から、多くのセキュリティ専門家はSameSite属性とCSRFトークンによる対策の併用を推奨しています。

トークンベースのCSRF対策

CSRFトークンの仕組み

CSRFトークン対策では、サーバーサイドで生成した予測不可能なトークンを用いて、リクエストの正当性を検証します。基本的な流れは以下の通りです:

  1. サーバーがユニークなトークンを生成
  2. トークンをセッションに保存し、フォームのhidden要素やJavaScriptに埋め込む
  3. リクエスト時にトークンを検証

この方法が効果的な理由は、Same-Origin Policy(同一オリジンポリシー)によって、攻撃者は別のドメインからトークンの値を読み取ることができないためです。

トークン対策の具体的な仕組み

重要なのは、攻撃者がトークンの値を知ることができないという点です。以下の図で簡単に説明します:

正規サイト(example.com):
「例えば以下のようなフォームにトークンを埋め込む」
<form action="/transfer" method="POST">
  <input type="hidden" name="csrf_token" value="abc123xyz">
  <input type="text" name="amount" value="1000">
  <input type="submit" value="送金">
</form>

攻撃者のサイト(evil.com):
「このフォームにはトークンがない、または無効なトークン」
<form action="https://example.com/transfer" method="POST">
  <input type="hidden" name="csrf_token" value="???">
  <input type="hidden" name="amount" value="100000">
  <input type="submit" value="当選しました!">
</form>

攻撃者はSame-Origin Policyにより、正規サイトのDOM内のトークンを読み取ることができないため、有効なトークンを含めることができません。結果として、サーバー側でトークン検証に失敗し、攻撃は防止されます。

CSRFによる実際の被害事例

CSRF攻撃による実際の被害事例をいくつか紹介します:

TikTokの脆弱性(2020年)

2020年、ByteDanceは、TikTokにCSRF脆弱性が報告されました。この脆弱性により、攻撃者はマルウェアを含むメッセージをTikTokユーザーに送信し、マルウェアの展開後にCSRF攻撃やXSS攻撃を実行して、他のユーザーアカウントに代わってリクエストを送信することができました。TikTokは3週間以内に脆弱性を修正しました。

Codeforcesのアカウント乗っ取り(2015年)

プログラミングコンテストプラットフォームのCodeforcesでは、CSRFトークンが実装されていたにもかかわらず、トークンの検証が行われていなかったため、アカウント乗っ取りの脆弱性が発見されました。攻撃者は、ユーザーのメールアドレスを変更し、パスワードリセット機能を使用してアカウントを乗っ取ることができました。

MySpaceの大規模ハッキング事件

かつて大人気だったSNSのMySpaceでは、CSRFとXSSの組み合わせにより、ユーザーの友達リストが大量に改ざんされる事件が発生しました。攻撃者は自分のプロフィールページにJavaScriptを埋め込み、訪問者を攻撃者のフレンドに自動的に追加するコードを実行させました。

ING Directの銀行送金脆弱性(2008年)

2008年、オランダの多国籍銀行グループINGの銀行ウェブサイトING Directに、CSRF脆弱性が発見されました。この脆弱性により、攻撃者はSSLで認証されたユーザーであっても、ユーザーのアカウントから資金を移動することが可能でした。ウェブサイトにはCSRF攻撃に対する保護がなく、資金移動のプロセスは攻撃者が簡単に複製して悪用できるものでした。

これらの事例からも、CSRF対策が不十分な場合、重大な被害につながる可能性があることがわかります。

Go言語での実装例

フレームワークを使った実装

主要なGoのWebフレームワークには、CSRF対策機能が組み込まれています。以下に代表的なフレームワークでの実装例を紹介します。

1. Gin Framework

import (
    "github.com/gin-gonic/gin"
    "github.com/gin-contrib/csrf"
)

func main() {
    r := gin.Default()
    
    // CSRFミドルウェアの設定
    r.Use(csrf.New(csrf.Options{
        Secret: "32バイトの秘密鍵",
        ErrorFunc: func(c *gin.Context) {
            c.String(403, "CSRF token 検証に失敗しました")
            c.Abort()
        },
        TokenLookup: "form:csrf_token,header:X-CSRF-Token",
        CookieName: "_csrf",
        CookieSameSite: http.SameSiteStrictMode,
    }))

    r.GET("/form", func(c *gin.Context) {
        c.HTML(200, "form.html", gin.H{
            "csrf": csrf.GetToken(c),
        })
    })
    
    r.POST("/submit", func(c *gin.Context) {
        // CSRFトークンは自動的に検証される
        c.String(200, "成功")
    })
    
    r.Run(":8080")
}

2. Echo Framework

import (
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

func main() {
    e := echo.New()
    
    // CSRFミドルウェアの設定
    e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
        TokenLookup: "form:csrf_token,header:X-CSRF-Token",
        CookieName: "_csrf",
        CookieSameSite: http.SameSiteStrictMode,
    }))

    e.GET("/form", func(c echo.Context) error {
        token := c.Get("csrf").(string)
        return c.Render(200, "form.html", map[string]interface{}{
            "csrf": token,
        })
    })
    
    e.POST("/submit", func(c echo.Context) error {
        // CSRFトークンは自動的に検証される
        return c.String(200, "成功")
    })
    
    e.Logger.Fatal(e.Start(":8080"))
}

3. Gorilla Mux + CSRF

import (
    "net/http"
    "github.com/gorilla/mux"
    "github.com/gorilla/csrf"
)

func main() {
    r := mux.NewRouter()
    
    // CSRFミドルウェアの設定
    csrfMiddleware := csrf.Protect(
        []byte("32バイトの秘密鍵"),
        csrf.Secure(true),
        csrf.SameSite(csrf.SameSiteStrictMode),
        csrf.Path("/"),
    )
    
    r.HandleFunc("/form", ShowForm)
    r.HandleFunc("/submit", ProcessForm).Methods("POST")
    
    http.ListenAndServe(":8080", csrfMiddleware(r))
}

func ShowForm(w http.ResponseWriter, r *http.Request) {
    // テンプレートにCSRFトークンを埋め込む
    tmpl.ExecuteTemplate(w, "form.html", map[string]interface{}{
        csrf.TemplateTag: csrf.TemplateField(r),
    })
}

func ProcessForm(w http.ResponseWriter, r *http.Request) {
    // CSRFトークンは自動的に検証される
    w.Write([]byte("成功"))
}

フレームワークを使わない実装

標準ライブラリとGorilla Sessionsを使用して、独自にCSRF対策を実装する例です。

import (
    "crypto/rand"
    "encoding/base64"
    "net/http"
    "time"
    "github.com/gorilla/sessions"
)

// CSRFトークン管理用の構造体
type CSRFProtection struct {
    store *sessions.CookieStore
}

// 新しいCSRFProtectionインスタンスの作成
func NewCSRFProtection(secretKey []byte) *CSRFProtection {
    return &CSRFProtection{
        store: sessions.NewCookieStore(secretKey),
    }
}

// トークンの生成
func (c *CSRFProtection) generateToken() (string, error) {
    b := make([]byte, 32)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return base64.StdEncoding.EncodeToString(b), nil
}

// トークンの保存
func (c *CSRFProtection) SaveToken(w http.ResponseWriter, r *http.Request) (string, error) {
    session, err := c.store.Get(r, "csrf-session")
    if err != nil {
        return "", err
    }

    token, err := c.generateToken()
    if err != nil {
        return "", err
    }

    session.Values["csrf_token"] = token
    session.Options = &sessions.Options{
        Path:     "/",
        MaxAge:   3600, // 1時間
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteStrictMode,
    }

    err = session.Save(r, w)
    return token, err
}

// トークンの検証
func (c *CSRFProtection) ValidateToken(r *http.Request) bool {
    session, err := c.store.Get(r, "csrf-session")
    if err != nil {
        return false
    }

    storedToken, ok := session.Values["csrf_token"].(string)
    if !ok {
        return false
    }

    // フォームからトークンを取得
    formToken := r.FormValue("csrf_token")
    // またはヘッダーからトークンを取得
    headerToken := r.Header.Get("X-CSRF-Token")

    // フォームまたはヘッダーのトークンと比較
    return formToken == storedToken || headerToken == storedToken
}

// CSRFミドルウェア
func (c *CSRFProtection) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // GET, HEAD, OPTIONSリクエストはスキップ
        if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
            next.ServeHTTP(w, r)
            return
        }

        // トークンの検証
        if !c.ValidateToken(r) {
            http.Error(w, "CSRF token validation failed", http.StatusForbidden)
            return
        }

        next.ServeHTTP(w, r)
    })
}

// 使用例
func main() {
    // 秘密鍵の生成(本番環境では環境変数などから取得)
    secretKey := []byte("32バイトの秘密鍵をここに設定")
    
    // CSRF保護の初期化
    csrf := NewCSRFProtection(secretKey)

    // ルーティングの設定
    mux := http.NewServeMux()

    // フォーム表示ハンドラ
    mux.HandleFunc("/form", func(w http.ResponseWriter, r *http.Request) {
        token, err := csrf.SaveToken(w, r)
        if err != nil {
            http.Error(w, "Failed to generate CSRF token", http.StatusInternalServerError)
            return
        }
        
        // トークンをフォームに埋め込む例
        html := `
        <form method="POST" action="/submit">
            <input type="hidden" name="csrf_token" value="` + token + `">
            <input type="text" name="message">
            <button type="submit">送信</button>
        </form>`
        
        w.Write([]byte(html))
    })

    // フォーム送信ハンドラ
    mux.Handle("/submit", csrf.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // CSRFトークンが検証されたので処理を実行
        w.Write([]byte("送信成功"))
    })))

    // サーバーの起動
    http.ListenAndServe(":8080", mux)
}

まとめ: SameSite属性の設定のみで本当に大丈夫?

CSRF対策としてSameSite属性とトークンベースの保護を比較した結果、以下のような知見が得られました:

SameSite属性のメリット

  • 実装が簡単:Cookieの属性を設定するだけで良い
  • ブラウザによる自動保護:ブラウザが自動的にCookieの送信を制限
  • パフォーマンスへの影響が少ない:追加の処理やストレージが不要

SameSite属性の限界

  • ブラウザの互換性:古いブラウザでは対応していない
  • ユーザー体験の低下Strict設定では、正当な外部リンクからのアクセスに問題が発生
  • サブドメイン間の問題:サブドメイン間でも制限が適用される場合がある

トークンベース保護のメリット

  • ブラウザに依存しない:全てのブラウザで機能する
  • より細かい制御:特定のリクエストのみ保護することが可能
  • サブドメイン間での柔軟性:サブドメイン間でも問題なく機能

トークンベース保護の課題

  • 実装の複雑さ:適切な実装が必要
  • パフォーマンスコスト:トークンの生成と検証に追加のリソースが必要
  • ユーザー側の状態管理:SPA(シングルページアプリケーション)では追加の対応が必要

推奨されるアプローチ:多層防御

セキュリティのベストプラクティスは「多層防御」です。最も堅牢なCSRF対策としては、以下の組み合わせを推奨します:

  1. Cookie設定の強化

    • SameSite=StrictまたはLaxの設定
    • SecureおよびHttpOnly属性の設定
  2. CSRFトークンの実装

    • サーバーサイドでのトークン生成と検証
    • フォームやAPIリクエストでのトークンの送信
  3. 追加の保護策

    • 重要な操作には再認証を要求
    • リファラーチェックの実装(補助的に)
    • セキュリティヘッダーの設定(X-Frame-Options等)

結論

SameSite属性の設定のみで本当に大丈夫か?」という問いに対する答えは、「状況による」です。リスクの低いWebサイトや、モダンブラウザのみをサポートする場合は、SameSite=Strictの設定だけでも十分な保護になる可能性があります。

しかし、銀行システム、医療情報、重要な個人情報を扱うサイトなど、セキュリティが特に重要なアプリケーションでは、SameSite属性と従来のCSRFトークンを組み合わせた多層防御アプローチを採用することを強く推奨します。

セキュリティは常に「コストとリスクのバランス」です。各アプリケーションのリスク評価に基づいて、適切なCSRF対策を選択しましょう。

Discussion