🐁

Golangで学ぶCookie

2023/02/05に公開

執筆のきっかけ

趣味でGoのアプリケーションで認証用のトークンをCookieにセットする処理を書いていました。
Cookie構造体を覗くと色々なプロパティがあり、詳しく調べてみようと思いました。
本記事ではまずCookieとは何かを調べ、最終的にGo上でどう扱えば良いのかを検討しましょう。

そもそもCookieとは?

MDNにはこうあります。

HTTP Cookie (ウェブ Cookie、ブラウザー Cookie) は、サーバーがユーザーのウェブブラウザーに送信する小さなデータであり、ブラウザーに保存され、その後のリクエストと共に同じサーバーへ返送されます。

一般的には、 2 つのリクエストが同じブラウザーから送信されたものであるかを知るために使用されます。例えば、ユーザーのログイン状態を維持することができます。

https://developer.mozilla.org/ja/docs/Web/HTTP/Cookies

GoでCookieをセット・読み込む簡単な実装を見てみましょう。

func setCookie(w http.ResponseWriter, r *http.Request) {
    cookie := &http.Cookie{
        Name:    "token",
        // 認証用のトークン。jwt入れることも多いと思います
        Value:   "example_token_value"
    }
    // サーバからブラウザにCookieを渡す処理
    http.SetCookie(w, cookie)
}

func readCookie(w http.ResponseWriter, r *http.Request) {
    // リクエストからCookieを受け取る処理
    token, err := r.Cookie("token")

    if token.Value == "example_token_value" {
        // 認証成功
    }
}

Cookieを体験するくらいならNameプロパティとValueプロパティで十分かも知れませんが、もっと色々な設定項目があります。

Cookie構造体を見てみる

GoのCookie構造体の実装を見ていく。

net/http/cookie.go
// A Cookie represents an HTTP cookie as sent in the Set-Cookie header of an
// HTTP response or the Cookie header of an HTTP request.
//
// See https://tools.ietf.org/html/rfc6265 for details.
type Cookie struct {
	Name  string
	Value string

	Path       string    // optional
	Domain     string    // optional
	Expires    time.Time // optional
	RawExpires string    // for reading cookies only

	// MaxAge=0 means no 'Max-Age' attribute specified.
	// MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0'
	// MaxAge>0 means Max-Age attribute present and given in seconds
	MaxAge   int
	Secure   bool
	HttpOnly bool
	SameSite SameSite
	Raw      string
	Unparsed []string // Raw text of unparsed attribute-value pairs
}

NameValueは分かる。さっき見た通り。それ以外のプロパティを追っていく。

Path属性

MDNから。

Path 属性は、 Cookie ヘッダーを送信するためにリクエストされた URL の中に含む必要がある URL のパスを示します。 %x2F ("/") の文字はディレクトリー区切り文字として解釈され、サブディレクトリーにも同様に一致します。

https://developer.mozilla.org/ja/docs/Web/HTTP/Cookies

前述のとおり、ブラウザは受け取ったCookieをサーバへのリクエスト時に自動送信するわけですが、その自動送信するパスの絞り込みが可能です。

省略すると、Cookieを発行したパスが入ります。
例えばhttps://example.com/loginみたいなAPIを叩いてcookieがセットされたとして、Path属性を設定していないと、Path=/loginになります。

Cookie構造体では設定はoptionalになっていますが、先ほどの例で言うと、/login以外のAPIをコールした時にはCookieが送られなくなるので、/を設定しておくのが良さそうです。

Domain属性

MDNから。

Domain 属性は、Cookie を受信することができるホストを指定します。指定されていない場合は、既定で Cookie を設定したのと同じホストとなり、サブドメインは除外されます。 Domain が指定された場合、サブドメインは常に含まれます。したがって、 Domain を指定すると省略時よりも制限が緩和されます。ただし、サブドメイン間でユーザーに関する情報を共有する場合は有用になるでしょう。

例えば、Domain=mozilla.org を設定すると、developer.mozilla.org のようなサブドメインも含まれます。

https://developer.mozilla.org/ja/docs/Web/HTTP/Cookies

設定を省略した方が制限が厳しいわけなので、特段事情がなければ(≒よく分からなければ)指定しないで良さそうです。
Cookie構造体にもoptionalとコメントがついています。

Expires属性

RFC6235から。

Expires属性は、クッキーの最大有効期限を示します。クッキーの有効期限が切れる日付と時刻で表されます。

https://www.rfc-editor.org/rfc/rfc6265#section-4.1.2.1

Cookieの期限をSat, 04 Feb 2023 08:40:45 GMTのような日付・時刻で表現します。
GoではTime構造体で表現できるので、あまり書式は意識しなくて済みますね。

余談ですがRFC6235内に日付の書式について記述がありますが、正直全然読めなかったので、実際のCookieからExpires属性の値を引っ張ってきました。

https://www.rfc-editor.org/rfc/rfc6265#section-5.1.1

Max-Age属性

RFC6235から。

Max-Age属性は、クッキーの最大有効期限を示します。
クッキーの有効期限までの秒数で表されます。
もしクッキーがMax-AgeとExpires属性の両方を持つ場合、Max-Age属性が優先され、クッキーの有効期限を管理します。
クッキーがMax-AgeとExpiresのどちらも持たない場合、ユーザーエージェントは属性がない場合、ユーザーエージェントは「現在のセッションが終了する」までそのクッキーを保持します。

https://www.rfc-editor.org/rfc/rfc6265#section-5.2.1:~:text=4.1.2.2. The Max-Age Attribute

有効期限までの秒数がMax-Age属性なので、Cookie構造体でint型で定義されているわけですね。
また優先順位もExpires属性より高く、Max-Age属性もExpires属性も両方なければ現在のセッションが終了するまでになるようです。(ブラウザなら閉じたら終わり)

Max-Age属性とExpires属性のどちらを設定すれば良いのか当然の疑問が生じますが、以下のような記事を見つけました。
IEがMax-Age属性をサポートしておらず、Expires属性の設定が必須という事情があったようです。

https://teratail.com/questions/43007

RFCにも以下のような注意書きがありました。

注意:いくつかの既存の利用者エージェントは,Max-Age属性をサポートしていません。 Max-Age属性をサポートしない利用者エージェントは、この属性を無視します。

IE対応の必要がもう終わった現在となっては、Max-Ageを使っていくべきなんでしょうか。
Max-Age属性に値を設定するような簡単なサーバを立て、Postmanでコールしてみました。
するとレスポンスヘッダーにはMax-Age属性があるにも関わらず、何故かExpires属性に置き換わっていました。
またChromeのdevツールでCookieの欄を見ると、Expires属性はありましたが、Max-Age属性はありませんでした。
うーん、RFCを読む限りはMax-Age属性優先とありますが、実用上はExpires属性の方が使われているんでしょうか。

Secure属性

MDNから。

Secure 属性がついた Cookie は HTTPS プロトコル上の暗号化されたリクエストでのみサーバーに送信され、安全でない HTTP では決して送信されないため、

https://developer.mozilla.org/ja/docs/Web/HTTP/Cookies

Goではbool型になっていて、trueにすればhttpsでない場合はブラウザからサーバにCookieが送られなくなります。
本番環境なりテスト環境にデプロイする際はtrue、ローカル開発時はfalseにするよう設定する感じでしょうか。

HttpOnly属性

MDNから。

HttpOnly 属性を持つ Cookie は、 JavaScript の Document.cookie API にはアクセスできません。サーバーに送信されるだけです。例えば、サーバー側のセッションを持続させる Cookie は JavaScript が利用する必要はないので、 HttpOnly 属性をつけるべきです。この予防策は、クロスサイトスクリプティング (XSS) 攻撃を緩和するのに役立ちます。

https://developer.mozilla.org/ja/docs/Web/HTTP/Cookies

同じくGoではbool型になっています。引用のとおり、trueにしておいて良いと思います。

SameSite属性

MDNから。

SameSite 属性により、サーバーがサイト間リクエスト (ここでサイトは登録可能なドメインによって定義されます) と一緒に Cookie を送るべきではないことを要求することができます。

取ることができる値は Strict, Lax, None の 3 つです。

https://developer.mozilla.org/ja/docs/Web/HTTP/Cookies

cookie.goから。

net/http/cookie.go
// SameSite allows a server to define a cookie attribute making it impossible for
// the browser to send this cookie along with cross-site requests. The main
// goal is to mitigate the risk of cross-origin information leakage, and provide
// some protection against cross-site request forgery attacks.
//
// See https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 for details.
type SameSite int

コメントを直訳すると、以下のとおり。

SameSiteは、サーバーがクッキーの属性を定義することで、ブラウザがクロスサイトリクエストとともにこのクッキーを送信することを不可能にすることができます。主な目的は、クロスオリジン情報漏えいのリスクを軽減し、クロスサイトリクエストフォージェリ攻撃からある程度保護することです。

前述のとおりブラウザはリクエスト時にCookieをサーバに自動で送信しますが、そのサーバの範囲を制限します。

Strictは名前のとおり厳しい制限で、Cookieを付与したサーバにのみ送信します。
例えばサイトAでログインしてサイトAのCookieをブラウザが保持していたとします。
そしてサイトBに掲載されているサイトAのリンクを踏んでサイトAを訪問した場合、CookieにはサイトAのものがあるわけですが、Strictの場合は送信しないので、未ログイン状態になります。

LaxStrict同様、Cookieを付与したサーバにのみ送信しますが、他のサイトから遷移してもcookieを送信します。
先ほどの例で言えば、サイトBからサイトAを訪問した際、サイトAのCookieを送信するので、ログイン状態のままです。

もう少し詳しく言うと、サイトBからサイトAにGETメソッドで遷移した場合は受け取れるようです。

Noneは先ほどの例で言えば、サイトB訪問時にサイトAのCookieを送信します。
この場合、Secure属性がtrueである、つまりhttps通信である必要があります。

結局何が受け取れるのか混乱しますね。
以下Qiita記事での実験が非常に分かりやすかったです。

https://qiita.com/akaaariiiiin/items/b3078b53621c79188f6e

特に必要がない限り、Strictが良いと思います。

RawExpiresプロパティ、Rawプロパティ、Unparsedプロパティ

Cookie構造体をどう扱うか

ポイントをまとめるとこんな感じ。

  1. Path属性は/を指定する。
  2. Domain属性は設定しない。
  3. Expires属性に有効期間を設定する。
  4. Max-Age属性は設定しない。(確信がないが今回はそうしてみる)
  5. Secure属性は本番環境は当然としてテスト環境等ではtrue、開発環境はfalseに環境変数等で切り替える。自己証明書等でローカルでもhttps通信できるならずっとtrueでも良いかも。
  6. HttpOnly属性はtrue。
  7. SameSite属性はStrict。
func setCookie(w http.ResponseWriter, r *http.Request) {
    cookie := &Cookie {
        Name: "token",
        Value: "example_token",
        Path: "/",
        Expires: time.Now().Add(60 * time.Minute), // 1時間設定する例。可能な限り短くする
        Secure: os.Getenv("COOKIE_SECURE"), // 本番とローカルで切り替えできるように
        HttpOnly: true,
        SameSite: http.SameSiteStrictMode,
    }
    // サーバからブラウザにCookieを渡す処理
    http.SetCookie(w, cookie)
}

おまけ:SetCookieメソッドのかるーいコードリーディングをやってみる

先ほどCookie構造体にDomain属性を設定していませんが、どのように処理しているのか気になったので、http.SetCookie関数を軽く読んでみました。
深い実装までは追わず、何をしているのかを追いかけるに留めたいと思います。

SetCookie関数はCookie構造体のStringメソッドを実行し、Cookieの文字列を生成してヘッダーにセットしています。

net/http/cookie.go
// SetCookie adds a Set-Cookie header to the provided ResponseWriter's headers.
// The provided cookie must have a valid Name. Invalid cookies may be
// silently dropped.
func SetCookie(w ResponseWriter, cookie *Cookie) {
	if v := cookie.String(); v != "" {
		w.Header().Add("Set-Cookie", v)
	}
}

ではどのようにCookieの文字列を生成するかStringメソッドを見てみましょう。
実装が長いので少しずつ抜粋しながら見ていきます。
まず全体像を俯瞰しましょう。

net/http/cookie.go
// String returns the serialization of the cookie for use in a Cookie
// header (if only Name and Value are set) or a Set-Cookie response
// header (if other fields are set).
// If c is nil or c.Name is invalid, the empty string is returned.
func (c *Cookie) String() string {
    if c == nil || !isCookieNameValid(c.Name) { // Cookieの名前に不適な文字が入っていないかチェック
        return ""
    }
    // extraCookieLength derived from typical length of cookie attributes
    // see RFC 6265 Sec 4.1.
    const extraCookieLength = 110
    var b strings.Builder // Cookieの文字列を生成する箱を作成
    b.Grow(len(c.Name) + len(c.Value) + len(c.Domain) + len(c.Path) + extraCookieLength) // 箱のキャパを設定する
    // 以下の3行でCookieの核になる`名前=値`を設定する。さっきまでの例だと、`token=example_token`になる。
    b.WriteString(c.Name)
    b.WriteRune('=')
    b.WriteString(sanitizeCookieValue(c.Value))

    // 省略

    return b.String() // Cookieの文字列を返却
}

読み進めていきましょう。
次はPath属性とDomain属性の設定です。

A leading dotってどういうケースであるのか調べたところ、サブドメインが有効という意味みたいです。
ただ古いブラウザで問題となったようで、今は気にしなくて良いようです。

https://stackoverflow.com/questions/9618217/what-does-the-dot-prefix-in-the-cookie-domain-mean

func (c *Cookie) String() string {
    // 省略

    // Pathプロパティがあれば書き込む
    if len(c.Path) > 0 {
        b.WriteString("; Path=")
        b.WriteString(sanitizeCookiePath(c.Path))
    }
    // Domainプロパティがあれば書き込む
    if len(c.Domain) > 0 {
        if validCookieDomain(c.Domain) {
            // A c.Domain containing illegal characters is not
            // sanitized but simply dropped which turns the cookie
            // into a host-only cookie. A leading dot is okay
            // but won't be sent.
            d := c.Domain
            // 先頭のドットを読み飛ばす
            if d[0] == '.' {
                d = d[1:]
            }
            b.WriteString("; Domain=")
            b.WriteString(d)
        } else {
            log.Printf("net/http: invalid Cookie.Domain %q; dropping domain attribute", c.Domain)
        }
    }

    // 省略
}

読み進めていきます。
TimeFormatは定数で中身がMon, 02 Jan 2006 15:04:05 GMTという日付の文字列でした。
結局分からなかったのですが、validCookieExpiresは中で引数のc.ExpiresYearメソッドを呼び出しています。
Expiresが初期値のnilだった時にぬるぽなりそうだったんですが、どこで回避してるんだろう...。

func (c *Cookie) String() string {
    // 省略

    var buf [len(TimeFormat)]byte
    // Expiresプロパティが適正であれば書き込む(条件式がよく分からなかった)
    if validCookieExpires(c.Expires) {
        b.WriteString("; Expires=")
        b.Write(c.Expires.UTC().AppendFormat(buf[:0], TimeFormat))
    }
    // MaxAgeプロパティが指定されていれば書き込む
    if c.MaxAge > 0 {
        b.WriteString("; Max-Age=")
        b.Write(strconv.AppendInt(buf[:0], int64(c.MaxAge), 10))
    } else if c.MaxAge < 0 {
        b.WriteString("; Max-Age=0")
    }
    // HttpOnlyプロパティがtrueなら書き込む
    if c.HttpOnly {
        b.WriteString("; HttpOnly")
    }
    // Secureプロパティがtrueなら書き込む
    if c.Secure {
        b.WriteString("; Secure")
    }

    // 省略
}

さて最後です。SameSite属性の処理をして終わりです。

func (c *Cookie) String() string {
    // 省略

    switch c.SameSite {
	case SameSiteDefaultMode:
		// Skip, default mode is obtained by not emitting the attribute.
	case SameSiteNoneMode:
		b.WriteString("; SameSite=None")
	case SameSiteLaxMode:
		b.WriteString("; SameSite=Lax")
	case SameSiteStrictMode:
		b.WriteString("; SameSite=Strict")
	}
	return b.String()
}

おわりに

Cookieよく分からんなと思って調べてみましたが、スッキリした面もあれば、謎が深まってしまったところもありました...。
ご意見あれば是非コメントいただけますと幸いです。

GitHubで編集を提案

Discussion