gorilla/csrf で安全なWebフォームを作る

こんにちは。GoでWeb開発していますか?私はしていません。Goに限らず、既成のWebアプリケーションフレームワークを使わずに自前でWebフォームを作る場合、なにも考えずに書くと CSRF (Cross Site Request Forgery) 脆弱性を作りこみ、不正なユーザー操作を実行されてしまう可能性があります。

ダメな例

例えば以下のGoコードで作成されるフォームにはCSRF脆弱性があります。SubmitSignupForm ハンドラは、受け取ったリクエストが自分のサイト上のフォームからサブミットされたものかチェックしていないので、攻撃者が他のサイト上のフォームを使い、第三者のユーザーのブラウザで任意の操作を実行させることができてしまいます。

func main() {
    r := mux.NewRouter()
    r.HandleFunc("/signup", ShowSignupForm)
    r.HandleFunc("/signup/post", SubmitSignupForm).Methods("POST")
    http.ListenAndServe(":8080", r)
}

func ShowSignupForm(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, `
<html>
  <body>
      <form method="POST" action="/signup/post" accept-charset="UTF-8">
          <input type="text" name="name">
          <input type="text" name="email">
          <input type="submit" value="Sign up!">
      </form>
  </body>
</html>`)
}

func SubmitSignupForm(w http.ResponseWriter, r *http.Request) {
    _ = r.ParseForm()
    fmt.Fprintf(w, "%v\n", r.PostForm)
    // TODO: ユーザー登録
}

CSRFについては「はまちちゃん事件」が有名です。mixiにログイン済みのユーザーにCSRF脆弱性のあったmixiの発言投稿用URLリンクを踏ませることで、ユーザーの意図しない発言を投稿させたというものです。CSRF対策にはいくつかの手法がありますが、権威あるセキュリティ団体であるOWASPが各手法の解説とメリット・デメリットを網羅しています。詳しく知りたい方はそちらを参照してください。

github.com

gorilla/csrf

信頼できるWebアプリケーションフレームワークではCSRFへの対策実装され、デフォルトで有効になっていますが、Goの net/http はWAFではないので、別途WAFを導入しないのであれば、自分でセキュリティ対策を導入する必要があります。そこで、今回は gorilla/csrf を使ってCSRF対策を実装します。

github.com

gorilla/csrf は net/http だけでなく、Echo や Gin といった人気のWAFと協調して動作することを目的としたCSRF対策パッケージです。

Cookieの二重送信(gorillaの場合)

gorilla/csrf が採用する対策手法は、OWASP の分類で言えば "Double Submit Cookie" (Cookieの二重送信) に分類されます。 Double Submit Cookieは、要点だけ言えばセッション開始時に暗号的に強い乱数でトークンを生成しておき、それをブラウザのCookieとフォームのhidden input要素の両方に送信するというものです。もちろん、サブミット時にはサーバーでCookieとフォームのトークンの一致を検証し、一致しなければエラーとします。この手法は、トークン情報をステートとして保持する必要がないため、実装・運用コストが低いのが特徴です。

一方、Cookieの二重送信には問題点も指摘されています。

要約すると、一定条件のもとではCookieを不正に書き換えることは可能であり、その場合に Double Submit Cookie の安全性は成立しないというものです。これについて、gorilla/csrfCookie に署名つき暗号化を施すことで回避しています。OWASPチートシートにも、Cookieの暗号化はうまく動作すると書かれています。

Including the token in an encrypted cookie - often within the authentication cookie - and then at the server side matching it (after decrypting authentication cookie) with the token in hidden form field or parameter/header for ajax calls mitigates both the issues mentioned above. This works because a sub domain has no way to over-write an properly crafted encrypted cookie without the necessary information such as encryption key.

また、gorilla/csrf では、リクエスト時に Unique-per-Request な one time padを生成し、CookieのtokenをXORでマスクしたものをフォームに送信する工夫を加えることで、BREACH攻撃を緩和しています。

プレーンな Double Submit Cookie の弱点を回避して、概ね安全なように思います。強いていえばユーザー自身が外部のWebページにフォームを送信してしまえば偽のフォームを作成可能ですが、そのケースはほぼ意図的な漏洩ですし、そこまで守るかどうかはポリシー次第(それも防ぎたかったらパスワード入力を求めるとか)じゃないでしょうか。

gorilla/csrfCSRF対策の流れを紹介します。

初回訪問時

最初にユーザーがWebサイトに訪れたとき、サーバーは暗号的に強い乱数生成器から basetoken を乱数生成します。そして basetoken を auth_key で署名つき暗号化したものを、レスポンスの _gorilla_csrf Cookie に格納します。この暗号は署名つき暗号なので、改竄に対する耐性があります。

f:id:ono_matope:20190605011412p:plain
初回訪問時

フォーム表示時

次に、サーバーが _gorilla_csrf Cookie を持ったクライアントにフォームを表示する際、サーバーはまず OTP (one-time-pad) を乱数生成します。この OTPと、basetokenをOTPでマスクした結果を結合し、CSRFトークンを生成します( csrfToken = OTP + XOR(basetoken, OTP) )。このCSRFトークンは、フォームのhidden inputとしてユーザーにレスポンスされます。

f:id:ono_matope:20190605092057p:plain
フォーム表示時

サブミット時

ユーザーがフォームをサブミットしてきたとき、サーバーはフォームのCSRFトークンからOTPを取り出し、Cookieのbasetokenを使って先ほどと同じ操作( OTP + XOR(basetoken, OTP)) をおこない、その結果がフォームのCSRFトークンと一致するか確かめることで、リクエストが正しく要求されたものか判別します。

f:id:ono_matope:20190605102640p:plain
サブミット時

How to use gorilla/csrf

さて、原理はわかったので使ってみましょう。

package main

import (
    "fmt"
    "html/template"
    "net/http"

    "github.com/gorilla/csrf"
    "github.com/gorilla/mux"
)

func main() {
    r := mux.NewRouter()
    r.HandleFunc("/signup", ShowSignupForm)
    r.HandleFunc("/signup/post", SubmitSignupForm).Methods("POST")

    // Protect ミドルウェアは、非冪等なHTTPメソッドの場合にサブミットされたフォーム
    // をチェックし、CSRFトークンが一致しているか検証する。
    //
    // auth-key はコードに書き込まず、/dev/urandom したものを外部から与えること。
    // キーが変わるとそれまでに発行したトークンの検証に失敗する。
    //
    // HTTP な開発環境では opt に csrf.Secure(false) 指定が必要。本番では外すこと。
    h := csrf.Protect([]byte("32-byte-long-auth-key"), csrf.Secure(false))(r)
    http.ListenAndServe(":8080", h)
}

func ShowSignupForm(w http.ResponseWriter, r *http.Request) {

    // {{.CSRFField}} にCSRFトークンの隠しinputを埋め込む。
    t, _ := template.New("form").Parse(`
<html>
  <body>
      <form method="POST" action="/signup/post" accept-charset="UTF-8">
          <input type="text" name="name">
          <input type="text" name="email">
          {{.CSRFField}}
          <input type="submit" value="Sign up!">
      </form>
  </body>
</html> `)
    t.Execute(w, map[string]interface{}{
        "CSRFField": csrf.TemplateField(r),
    })
}

func SubmitSignupForm(w http.ResponseWriter, r *http.Request) {
    _ = r.ParseForm()
    fmt.Fprintf(w, "%v\n", r.PostForm)
    // TODO: ユーザー登録
}

csrf.ProtectCSRF保護機能を提供するHTTPミドルウェアを作成します。このミドルウェアは、POSTなどの非安全リクエストを受けた時にリクエストボディのフォームを自動的にチェックし、CSRFトークンが適格であれば次のハンドラを実行し、適格でなければエラー画面を表示して終了します。そのため、アプリケーション側のHTTPハンドラは(サブミットについては)CSRF検証済みであることを前提に開発することができます。OWASPチートシートにも、セキュリティ対策で怖いのはうっかりミスなので自動的な対策をせよと書かれているので、この仕様は歓迎できます。

一方、csrf.TemplateField(r *http.Request) template.HTML は、リクエストのCSRFトークンのhidden inputタグをリターンします。これをtemplateで置換してHTMLフォームを表示すると、gorilla.csrf.Token という名前の 隠し inputタグが埋め込まれます。

f:id:ono_matope:20190605144134p:plain
出力されるHTMLフォーム

同時に、Cookieにも _gorilla_csrf が記録されているのがわかります(内容は暗号化されているので確認も改竄もできません)。

f:id:ono_matope:20190605142544p:plain
Cookie

そこで、わざとフォームの値を書き換えてからSubmitすると、ちゃんとエラーになります。

f:id:ono_matope:20190605141731p:plain
シンプルなエラー画面

ところで、csrf.Protectはトークン不一致に簡単なエラー画面を出力するのですが、オプションでカスタムのエラーハンドラを指定させることができます。以下のように、何らかのセキュリティ対応を取れるようなログを落とすと良いと思います。

   h := csrf.Protect([]byte("32-byte-long-auth-key"),
        csrf.ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            log.Println("CSRF攻撃の疑いのあるリクエストが発行されました")
            fmt.Fprintf(w, "ニャーン (%s)", csrf.FailureReason(r).Error())
        })),
        csrf.Secure(false),
    )(r)

f:id:ono_matope:20190605142904p:plain
にゃーん

以上です。