GoのCSRF対策—CrossOriginProtectionでトークンが不要になる仕組み

GoのCSRF対策—CrossOriginProtectionでトークンが不要になる仕組み | mohablog
目次

トークンもCookieも配らないCSRF対策

POSTのたびにhiddenのcsrf_tokenを埋め込み、Cookieと突き合わせる。Go 1.25で標準ライブラリに入ったnet/http.CrossOriginProtectionは、この配線を丸ごと省きます。判定材料はブラウザが送るSec-Fetch-Siteヘッダひとつ。

リリースノートのnet/httpセクションはこう書きます。

The new CrossOriginProtection implements protections against Cross-Site Request Forgery (CSRF) by rejecting non-safe cross-origin browser requests. It uses modern browser Fetch metadata, doesn’t require tokens or cookies, and supports origin-based and pattern-based bypasses.

「doesn’t require tokens or cookies」の部分が、そのまま実装量の差になります。動作確認は現行のgo1.26.4で行いました。追加自体はGo 1.25ですが、1.26でも挙動は変わりません。

従来のトークン方式が増やしていた配線

gorilla/csrfのようなトークン方式は、3つの部品を要求します。フォームごとのhiddenフィールド、トークンを載せるCookie、サーバ側の突き合わせ。SPAだとHTMLにトークンを差し込めないので、専用エンドポイントでトークンを取り、リクエストヘッダに載せ直す。この往復のぶん、フロントとサーバ双方にコードが増えます。

CrossOriginProtectionはこの3部品を要求しません。フォームにもJavaScriptにも手を入れず、サーバ側のハンドラを1枚かぶせるだけ。

Sec-Fetch-Siteを信じるという設計

Sec-Fetch-Siteは、リクエストがどこから発生したかをブラウザ自身が付けるヘッダです。同一オリジンならsame-origin、別サイトのフォームやfetchからならcross-site。値を決めるのはブラウザなので、攻撃者のページからsame-originを詐称できません。MDNによれば2023年以降のブラウザはすべて送出します。

この前提に乗るなら、サーバ側はヘッダを1つ読むだけ。トークンの生成も検証も、CookieのSameSite調整も要りません。

net/httpだけで組み込む

NewCrossOriginProtectionでハンドラを包む

使うのはNewCrossOriginProtectionHandlerの2つだけ。前者で値を作り、後者で既存のmuxを包みます。

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /transfer", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "transfer done")
	})
	mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	})

	csrf := http.NewCrossOriginProtection()
	log.Fatal(http.ListenAndServe(":8099", csrf.Handler(mux)))
}

別ターミナルからcurlで叩きます。安全メソッドのGETと、Sec-Fetch-Siteを詐称できない状況を並べます。

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8099/
200

$ curl -s -X POST -H "Sec-Fetch-Site: cross-site" http://localhost:8099/transfer
cross-origin request detected from Sec-Fetch-Site header

GETは200。クロスサイトのPOSTは403で、ボディに拒否理由がそのまま出ます。文字列cross-origin request detected from Sec-Fetch-Site headerは標準ライブラリが返すもので、自分で書いたものではありません。

ゼロ値でもそのまま動く

ドキュメントは「The zero value of CrossOriginProtection is valid and has no trusted origins or bypass patterns.」と書きます。var csrf http.CrossOriginProtectionで作った値も、信頼オリジンとバイパスが空なだけで有効。とりあえず全POSTを保護したいだけならNewCrossOriginProtection()とどちらでも同じ結果になります。

何が通って何が弾かれるか

判定はSec-Fetch-Siteの値でほぼ決まります。手元のgo1.26.4で全パターンを叩いた結果です。

リクエスト結果
GET / HEAD / OPTIONS常に許可
Sec-Fetch-Site: same-origin200 許可
Sec-Fetch-Site: none(直接遷移・ブックマーク)200 許可
Sec-Fetch-Site: same-site(別サブドメイン)403 拒否
Sec-Fetch-Site: cross-site403 拒否
ヘッダなし(Sec-Fetch-SiteもOriginも無い)200 許可
Sec-Fetch-Siteなし・Origin≠Host403 拒否

引っかかるのはsame-site。同じ登録ドメインでもサブドメインが違えばcross-siteではなくsame-siteになり、これも403で弾かれます。admin.example.comからexample.comのAPIを叩く構成は、素のままでは通りません。

別ドメインのフロントを通すAddTrustedOrigin

scheme://host[:port]で許可リストに足す

フロントとAPIをドメイン分離している場合、クロスサイトのPOSTを一律拒否されると困ります。AddTrustedOriginで通したいOriginを登録します。

csrf := http.NewCrossOriginProtection()
if err := csrf.AddTrustedOrigin("https://app.example.com"); err != nil {
	log.Fatal(err)
}

登録済みOriginからクロスサイトのPOSTを送ると通ります。

$ curl -s -X POST -H "Sec-Fetch-Site: cross-site" \
    -H "Origin: https://app.example.com" http://localhost:8099/transfer
transfer done

値の形式はscheme://host[:port]。不正な形式を渡すとAddTrustedOriginerrorを返すので、戻り値は捨てずにチェックします。

サブドメインは信頼オリジンに要る

前掲のsame-site問題の対処もこれです。admin.example.comからexample.comを叩くなら、csrf.AddTrustedOrigin("https://admin.example.com")を足す。登録はドキュメント記載の通りCheckHandlerの実行と並行して呼べて、以降のリクエストに効きます。

拒否レスポンスを設計する

403のボディをJSONに差し替える

デフォルトの拒否はプレーンテキストの403です。APIサーバなら、エラー形式を他のレスポンスと揃えたい。SetDenyHandlerで差し替えます。

csrf.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusForbidden)
	json.NewEncoder(w).Encode(map[string]string{"error": "cross-origin blocked"})
}))
$ curl -s -X POST -H "Sec-Fetch-Site: cross-site" \
    -H "Origin: https://evil.example" http://localhost:8099/transfer
{"error":"cross-origin blocked"}

ステータスは自分でWriteHeader(http.StatusForbidden)を書きます。デフォルトの「default error handler responds with a 403 Forbidden status」を踏襲するかどうかは実装側の判断。

Checkで判定だけ借りる

ハンドラを丸ごと包む形が合わないこともあります。既存のミドルウェアチェーンの中で、認証を通したあとに判定したい、といった場合。Check*http.Requestを受け取り、弾くべきならerrorを返すだけのメソッドです。

if err := csrf.Check(r); err != nil {
	http.Error(w, err.Error(), http.StatusForbidden)
	return
}

同じクロスサイトのPOSTなら、errにはcross-origin request detected from Sec-Fetch-Site headerが入ります。ドキュメントは「Check does not call the error handler.」と明記するので、SetDenyHandlerで登録した処理はCheck経由では走りません。拒否時のレスポンスは呼び出し側で書く前提です。ログ出力やメトリクス加算を挟むならここ。

gorilla/csrfのトークン方式が今も要る場面

CrossOriginProtectionはブラウザ前提の防御です。判定の土台がSec-Fetch-SiteとOrigin。ここに穴があるぶん、置き換えられない領域が残ります。

境界を示すのがドキュメントのこの一文。

Requests without Sec-Fetch-Site or Origin headers are currently assumed to be either same-origin or non-browser requests, and are allowed.

curlやサーバ間通信のように、どちらのヘッダも付かないリクエストは素通りします。手元の検証でも、ヘッダなしのPOSTは200で通りました。つまりこれは認証の代わりにはなりません。APIキーやトークン認証は別途要ります。もう一点、2023年より前のブラウザはSec-Fetch-Siteを送らないことがあり、その場合はOriginとHostの比較にフォールバックします。ヘッダに依存しない多層防御が要るなら、トークン方式の併用を検討する判断になります。

本番前に確認する落とし穴

受け口のエンドポイントはバイパスする

外部サービスからのWebhookは、ブラウザ発ではないのでSec-Fetch-SiteもOriginも付かず、そもそも素通りします。ただ、意図を明示するなら検査対象から外しておく手もあります。AddInsecureBypassPatternで特定パスを外します。

csrf.AddInsecureBypassPattern("/webhook")
$ curl -s -X POST -H "Sec-Fetch-Site: cross-site" \
    -H "Origin: https://evil.example" http://localhost:8099/webhook
webhook received

パターン構文はServeMuxと同じで、メソッド指定やワイルドカードも書けます。名前がInsecureで始まる通り、外したパスはCSRF検査を通りません。状態を変える処理を置くなら、別の認証で守る前提です。

リバースプロキシ配下のHostずれ

運用に出してから最初に出る403は、たいていここが原因です。Sec-Fetch-Siteを送らない古いブラウザや、ヘッダを落とすプロキシ経由だと、判定はOriginとHostの比較に落ちます。TLSをプロキシで終端し、バックエンドが受け取るHostが内部アドレスだと、ブラウザが送るOrigin(https://example.com)とHostが一致せず、正当なリクエストが403になります。手元でもSec-Fetch-Siteを外したPOSTだけがHost比較のパスに落ち、返る文字列がcross-origin request detected, and/or browser is out of date: Sec-Fetch-Site is missing, and Origin does not match Hostに変わるのを確認できました。

対処は2択。プロキシでHostヘッダを元のホスト名のまま渡すか、AddTrustedOriginで正規のOriginを登録する。ロードバランサ配下で原因不明の403が出たら、まずリクエストのHostとOriginを突き合わせます。

安全メソッドで状態を変えていないか

ドキュメントが釘を刺す一文があります。

It’s important that applications do not perform any state changing actions due to requests with safe methods.

GET・HEAD・OPTIONSは常に許可されます。GETで削除や更新をやっていると、その経路はCrossOriginProtectionの外側。GETに副作用を持たせない設計が、この防御の前提です。

まとめ

  • net/http.CrossOriginProtectionはGo 1.25で追加。トークンもCookieも使わずCSRFを防ぐ
  • 判定はSec-Fetch-Siteヘッダ。無ければOriginとHostの比較にフォールバックする
  • same-siteも拒否対象。別サブドメインはAddTrustedOriginで明示的に通す
  • ヘッダの付かない非ブラウザリクエストは素通りするので、認証の代替にはならない
  • リバースプロキシでHostがずれると正当なリクエストが403になる。Host転送か信頼オリジン登録で対処
  • 拒否レスポンスはSetDenyHandler、判定だけを借りるならCheck
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次