Golangで学ぶCookie
執筆のきっかけ
趣味でGoのアプリケーションで認証用のトークンをCookieにセットする処理を書いていました。
Cookie
構造体を覗くと色々なプロパティがあり、詳しく調べてみようと思いました。
本記事ではまずCookieとは何かを調べ、最終的にGo上でどう扱えば良いのかを検討しましょう。
そもそもCookieとは?
MDNにはこうあります。
HTTP Cookie (ウェブ Cookie、ブラウザー Cookie) は、サーバーがユーザーのウェブブラウザーに送信する小さなデータであり、ブラウザーに保存され、その後のリクエストと共に同じサーバーへ返送されます。
一般的には、 2 つのリクエストが同じブラウザーから送信されたものであるかを知るために使用されます。例えば、ユーザーのログイン状態を維持することができます。
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
構造体の実装を見ていく。
// 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
}
Name
とValue
は分かる。さっき見た通り。それ以外のプロパティを追っていく。
Path属性
MDNから。
Path 属性は、 Cookie ヘッダーを送信するためにリクエストされた URL の中に含む必要がある URL のパスを示します。 %x2F ("/") の文字はディレクトリー区切り文字として解釈され、サブディレクトリーにも同様に一致します。
前述のとおり、ブラウザは受け取った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 のようなサブドメインも含まれます。
設定を省略した方が制限が厳しいわけなので、特段事情がなければ(≒よく分からなければ)指定しないで良さそうです。
Cookie
構造体にもoptionalとコメントがついています。
Expires属性
RFC6235から。
Expires属性は、クッキーの最大有効期限を示します。クッキーの有効期限が切れる日付と時刻で表されます。
Cookieの期限をSat, 04 Feb 2023 08:40:45 GMT
のような日付・時刻で表現します。
GoではTime
構造体で表現できるので、あまり書式は意識しなくて済みますね。
余談ですがRFC6235内に日付の書式について記述がありますが、正直全然読めなかったので、実際のCookieからExpires属性の値を引っ張ってきました。
Max-Age属性
RFC6235から。
Max-Age属性は、クッキーの最大有効期限を示します。
クッキーの有効期限までの秒数で表されます。
もしクッキーがMax-AgeとExpires属性の両方を持つ場合、Max-Age属性が優先され、クッキーの有効期限を管理します。
クッキーがMax-AgeとExpiresのどちらも持たない場合、ユーザーエージェントは属性がない場合、ユーザーエージェントは「現在のセッションが終了する」までそのクッキーを保持します。
有効期限までの秒数がMax-Age属性なので、Cookie
構造体でint型で定義されているわけですね。
また優先順位もExpires属性より高く、Max-Age属性もExpires属性も両方なければ現在のセッションが終了するまでになるようです。(ブラウザなら閉じたら終わり)
Max-Age属性とExpires属性のどちらを設定すれば良いのか当然の疑問が生じますが、以下のような記事を見つけました。
IEがMax-Age属性をサポートしておらず、Expires属性の設定が必須という事情があったようです。
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 では決して送信されないため、
Goではbool型になっていて、trueにすればhttpsでない場合はブラウザからサーバにCookieが送られなくなります。
本番環境なりテスト環境にデプロイする際はtrue、ローカル開発時はfalseにするよう設定する感じでしょうか。
HttpOnly属性
MDNから。
HttpOnly 属性を持つ Cookie は、 JavaScript の Document.cookie API にはアクセスできません。サーバーに送信されるだけです。例えば、サーバー側のセッションを持続させる Cookie は JavaScript が利用する必要はないので、 HttpOnly 属性をつけるべきです。この予防策は、クロスサイトスクリプティング (XSS) 攻撃を緩和するのに役立ちます。
同じくGoではbool型になっています。引用のとおり、trueにしておいて良いと思います。
SameSite属性
MDNから。
SameSite 属性により、サーバーがサイト間リクエスト (ここでサイトは登録可能なドメインによって定義されます) と一緒に Cookie を送るべきではないことを要求することができます。
取ることができる値は Strict, Lax, None の 3 つです。
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
の場合は送信しないので、未ログイン状態になります。
Lax
はStrict
同様、Cookieを付与したサーバにのみ送信しますが、他のサイトから遷移してもcookieを送信します。
先ほどの例で言えば、サイトBからサイトAを訪問した際、サイトAのCookieを送信するので、ログイン状態のままです。
もう少し詳しく言うと、サイトBからサイトAにGETメソッドで遷移した場合は受け取れるようです。
None
は先ほどの例で言えば、サイトB訪問時にサイトAのCookieを送信します。
この場合、Secure属性がtrueである、つまりhttps通信である必要があります。
結局何が受け取れるのか混乱しますね。
以下Qiita記事での実験が非常に分かりやすかったです。
特に必要がない限り、Strict
が良いと思います。
RawExpiresプロパティ、Rawプロパティ、Unparsedプロパティ
Cookie構造体をどう扱うか
ポイントをまとめるとこんな感じ。
- Path属性は
/
を指定する。 - Domain属性は設定しない。
- Expires属性に有効期間を設定する。
- Max-Age属性は設定しない。(確信がないが今回はそうしてみる)
- Secure属性は本番環境は当然としてテスト環境等ではtrue、開発環境はfalseに環境変数等で切り替える。自己証明書等でローカルでもhttps通信できるならずっとtrueでも良いかも。
- HttpOnly属性はtrue。
- 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の文字列を生成してヘッダーにセットしています。
// 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
メソッドを見てみましょう。
実装が長いので少しずつ抜粋しながら見ていきます。
まず全体像を俯瞰しましょう。
// 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
ってどういうケースであるのか調べたところ、サブドメインが有効という意味みたいです。
ただ古いブラウザで問題となったようで、今は気にしなくて良いようです。
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.Expires
のYear
メソッドを呼び出しています。
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よく分からんなと思って調べてみましたが、スッキリした面もあれば、謎が深まってしまったところもありました...。
ご意見あれば是非コメントいただけますと幸いです。
Discussion