Skip to content

送れるのに読めない(同一生成元と CORS)

実装: crypto/sameorigin/ / 実行: go test ./crypto/sameorigin/

別のサイトへは送れる。返ってきたものが読めないだけだ。名前が「同一生成元でないと駄目」に見えるので、逆に覚えられていることが多い。6通りのやり方を並べて測ると、送れるのが6、読めるのが0になった。クッキーも付いて飛ぶ。CORS は読めないほうを開ける仕組みで、開け方を間違えると許可の一覧が空のまま全部開く。

この章で作るもの

ブラウザが別のサイトを触るときの決まりを実装して、何が通って何が止まるかを測る。

まず言葉を1つ。生成元(オリジン)というのは、スキーム・ホスト・ポートの3つ組のことだ。https://example.comhttp://example.com は別、example.comapi.example.com も別になる。この3つ組が一致するときだけ「同じ生成元」だ。

そして同一生成元ポリシーは、名前から想像するものと中身が違う。別の生成元へ行くこと自体は止めない。止めるのは、返ってきたものを読むことだけだ。

  あなたのページ                              別のサイト
  (https://evil.test)                    (https://bank.example)

    <form action=…> ────── 送信 ────────▶  受け取る・処理する
    <img src=…>     ────── 送信 ────────▶  ログに残る
    fetch(…)        ────── 送信 ────────▶  実行される

    JavaScript      ◀───── 応答 ─────────────┘
        ✗ 読めない          (ブラウザが握りつぶす)

  送るのは通る。読むところで止まる。
  クッキーも付いて飛ぶ ── ここが CSRF の足場になる
別の生成元へ触ったときに何が通るか。矢印は届き、応答も返ってくる。読めないのはスクリプトからだけになる

順に見ていく。

  1. 生成元は3つ組で、パスは入らない: 同じホストの別ページは、互いに同じ生成元になる
  2. 止まるのは読み取りだけで、送信は通る: 6通りで測ると、送れるのが6、読めるのが0
  3. 問い合わせが要る境目は、フォームで送れた範囲で決まっている: JSON が境目をまたぐ
  4. 開け方を間違えると、一覧が空のまま全開になる: 設定を読んでも気づけない

① 生成元は3つ組で、パスは入らない

まず判定を作る。

go

// Origin は生成元。スキーム・ホスト・ポートの3つ組で、1つでも違えば別物になる。
type Origin struct {
	Scheme string
	Host   string
	Port   int
}

// 既定ポート。URL に書かれていなければこれが補われる。
var defaultPort = map[string]int{"http": 80, "https": 443}

// Parse は URL を生成元にする。
//
// ホストとスキームは小文字に畳む。ポートは省略されていれば既定を補う。
// パスやクエリは生成元に含まれない。ここを含めないのが要点で、
// 同じホストの別ページは互いに同じ生成元になる。
func Parse(raw string) (Origin, error) {
	u, err := url.Parse(raw)
	if err != nil {
		return Origin{}, err
	}
	o := Origin{
		Scheme: strings.ToLower(u.Scheme),
		Host:   strings.ToLower(u.Hostname()),
	}
	if p := u.Port(); p != "" {
		// url.Parse がここまでで数字であることを保証している。
		o.Port, _ = strconv.Atoi(p)
	} else {
		o.Port = defaultPort[o.Scheme]
	}
	return o, nil
}

// Same は2つが同じ生成元か。3つ組がすべて一致したときだけ真になる。
func Same(a, b Origin) bool {
	return a.Scheme == b.Scheme && a.Host == b.Host && a.Port == b.Port
}

// String は `Origin` ヘッダに載る形。既定ポートは省く。
func (o Origin) String() string {
	if o.Port == defaultPort[o.Scheme] {
		return o.Scheme + "://" + o.Host
	}
	return o.Scheme + "://" + o.Host + ":" + strconv.Itoa(o.Port)
}

https://example.com/login を基準にして、何が同じで何が違うかを測った。

相手同じ生成元か
https://example.com/admin/secret同じパスは生成元に入らない
https://example.com:443/同じ既定ポートは書いても書かなくても同じ
https://EXAMPLE.COM/同じホストの大文字小文字は畳まれる
http://example.com/違うスキームが違う
https://example.com:8443/違うポートが違う
https://api.example.com/違うサブドメインは別のホスト
https://example.com.evil.test/違う前方一致は別物

テストで、この7通りの判定を固定した。

読みどころは1行目だ。パスが入らないので、/login/admin/secret は同じ生成元になる。同じホストに置いたものは、互いに全部読める。だから「管理画面だけ別ディレクトリに置いた」は境界にならない。境界にしたければ、ホストかポートを変えることになる。

最後の行も効く。example.com.evil.test は、文字列としては example.com で始まる。ホスト名の比較を前方一致で書くと通ってしまう。3つ組の完全一致でなければならない理由がここにある。

② 止まるのは読み取りだけで、送信は通る

ここがこの章の中心になる。やり方ごとに、送れるか・読めるか・クッキーが付くかを並べた。

go

// Access は、あるやり方で別の生成元へ触ったときに何が通るか。
type Access struct {
	// Send は要求が相手に届くか。既定ではほとんど届く。
	Send bool
	// Read は応答を JavaScript から読めるか。既定ではほとんど読めない。
	Read bool
	// Cookie は相手のクッキーが自動で付くか。CSRF はここに乗る。
	Cookie bool
}

// CrossOrigin は、代表的なやり方ごとに既定で何が通るかを返す。
//
// 表にすると分かるが、Send はほぼ全部 true で、Read はほぼ全部 false になる。
// 「送れるのに読めない」というのがこの仕組みの実態だ。
func CrossOrigin(kind string) Access {
	switch kind {
	case "form": // <form method=POST action=別オリジン>
		return Access{Send: true, Read: false, Cookie: true}
	case "img": // <img src=別オリジン>
		return Access{Send: true, Read: false, Cookie: true}
	case "script": // <script src=別オリジン>
		return Access{Send: true, Read: false, Cookie: true}
	case "link": // <a href=別オリジン>
		return Access{Send: true, Read: false, Cookie: true}
	case "fetch": // fetch(別オリジン)。既定では credentials を付けない
		return Access{Send: true, Read: false, Cookie: false}
	case "fetch-credentials": // fetch(別オリジン, {credentials:"include"})
		return Access{Send: true, Read: false, Cookie: true}
	default:
		return Access{}
	}
}

// Kinds は CrossOrigin が答えられるやり方。
func Kinds() []string {
	return []string{"form", "img", "script", "link", "fetch", "fetch-credentials"}
}
やり方送れる読めるクッキーが付く
<form> の送信はいいいえはい
<img src>はいいいえはい
<script src>はいいいえはい
リンクを踏むはいいいえはい
fetch()はいいいえいいえ
fetch(…, {credentials:"include"})はいいいえはい

送れるのが6通り、読めるのが0通りになった。テストで、全部のやり方で送れることと、既定ではどれも読めないことを固定してある。

この非対称に名前を付けておくと、あとの話が全部つながる。

送信が止められないのは、歴史の都合だ。フォームで別のサイトへ送るのも、別のサイトの画像を貼るのも、ブラウザに同一生成元ポリシーが入る前から普通にできていた。いまさら止めると既存のページが全部壊れる。だから送信は残し、読み取りだけを止めた

そしてクッキーが付いて飛ぶ。あなたが bank.example にログインしている状態で、evil.test のページがフォームを送ると、そのフォームには bank.example のクッキーが付く。銀行から見れば、ログイン済みの本人からの要求と区別がつかない。攻撃者は応答を読めないが、送るだけで用が足りるなら読む必要が無い。送金の実行に、応答は要らない。

これが CSRF の足場になる。止め方は次の章で作る。

fetch だけクッキーが付かないのも読んでおきたい。fetch は同一生成元ポリシーより後にできたので、最初から安全側の既定を選べた。付けたければ明示する。古いものは既定が緩く、新しいものは既定が厳しい、という差がそのまま残っている。

動かす

下のデモは、やり方と生成元を選んで、何が通るかを見比べる。

デモ同一生成元ポリシーと CORS送れる 6 / 読める 0
送れる / 読める生成元の比較先に問い合わせるかCORS の設定
やり方送れる読めるクッキー
<form> の送信通る読めない付く
<img src>通る読めない付く
<script src>通る読めない付く
リンクを踏む通る読めない付く
fetch()通る読めない付かない
fetch(…, {credentials:"include"})通る読めない付く
6 通りのどれでも送れて、どれも読めない。クッキーが自動で付くのは 5 通り。攻撃者は応答を読めないが、送るだけで足りる用件なら読む必要が無い

同一生成元ポリシーは「別のサイトへ行かせない」仕組みではない。行けるし、届くし、クッキーも付く。 応答をスクリプトから読ませないだけだ。CORS はその読み取りを開ける仕組みで、送信のほうは別に止める必要がある。

③ 問い合わせが要る境目は、フォームで送れた範囲で決まっている

②で「読み取りだけを止めた」と書いたが、実際には送信の側にも一部だけ制限が入っている。単純要求に当てはまらない要求は、本番の前にブラウザが OPTIONS を投げて許可を確かめる。これがプリフライトだ。

どこで線が引かれているか。

go

// Request はブラウザが出そうとしている要求のうち、判定に使う部分。
type Request struct {
	Method      string
	ContentType string
	Headers     []string // 独自に足したヘッダの名前
}

// 単純要求として通るメソッド。これ以外は先に問い合わせが要る。
var simpleMethods = map[string]bool{"GET": true, "HEAD": true, "POST": true}

// 単純要求として通る Content-Type。JSON がここに無いのが効いてくる。
var simpleTypes = map[string]bool{
	"application/x-www-form-urlencoded": true,
	"multipart/form-data":               true,
	"text/plain":                        true,
}

// 単純要求として通るヘッダ。だいたいブラウザが勝手に付けるものだけになる。
var simpleHeaders = map[string]bool{
	"accept": true, "accept-language": true, "content-language": true,
	"content-type": true,
}

// NeedsPreflight は、送る前に許可を問い合わせる必要があるか。
//
// 「単純要求」に当てはまらなければ、ブラウザは本番の要求の前に OPTIONS を
// 投げて許可を確かめる。ここで大事なのは、単純要求の条件が
// **フォームで送れる範囲**とほぼ重なっていることだ。
// フォームで送れるものは昔から送れていたので、いまさら止められない。
func NeedsPreflight(r Request) bool {
	if !simpleMethods[strings.ToUpper(r.Method)] {
		return true
	}
	if ct := mediaType(r.ContentType); ct != "" && !simpleTypes[ct] {
		return true
	}
	for _, h := range r.Headers {
		if !simpleHeaders[strings.ToLower(h)] {
			return true
		}
	}
	return false
}

func mediaType(ct string) string {
	if i := strings.IndexByte(ct, ';'); i >= 0 {
		ct = ct[:i]
	}
	return strings.ToLower(strings.TrimSpace(ct))
}
要求先に問い合わせるか
GETいいえ
POST + フォーム形式いいえ
POST + multipart/form-dataいいえ
POST + text/plainいいえ
POST + application/jsonはい
PUT / DELETEはい
GET + 独自ヘッダはい
GET + Acceptいいえ

テストで、この9通りを固定した。

線の位置に意味がある。問い合わせなしで通るものは、だいたい <form> で送れたものだ。フォームのメソッドは GETPOST しかなく、enctype は3種類しかない。つまり昔から送れていたものは、いまも問い合わせなしで送れる。それ以外は新しいので、確かめてから送れる。

だからここでも②と同じ理屈が効いている。既に送れていたものは止められない。止められるのは、後から増えたものだけになる。

実務でこれが顔を出すのは、Content-Typeapplication/json にした瞬間だ。同じ POST なのに、突然 OPTIONS が飛ぶようになる。サーバ側が OPTIONS に答えないと、本番の要求が一度も届かない。「サーバのログに何も来ていないのに失敗する」という形で出る。

そして裏返すと、JSON にしておくとプリフライトが挟まるぶん、フォーム形式より CSRF が起きにくい。攻撃者のページからは OPTIONS に答えてもらえないからだ。ただしこれは副産物であって、対策として頼るものではない。

④ 開け方を間違えると、一覧が空のまま全開になる

読めないものを読ませるのが CORS だ。サーバが「この生成元には読ませてよい」と返す。

go

// Policy はサーバが「どこに読ませるか」を決めた設定。
type Policy struct {
	// Allow は読ませる生成元。
	Allow []string
	// Wildcard は「どこでも読んでよい」。
	Wildcard bool
	// Credentials はクッキー付きの要求でも読ませるか。
	Credentials bool
	// Reflect は受け取った Origin をそのまま返す実装。事故のもとになる。
	Reflect bool
}

// Decision はサーバが返す答え。
type Decision struct {
	// AllowOrigin は Access-Control-Allow-Origin に載せる値。空なら返さない。
	AllowOrigin string
	// AllowCredentials は Access-Control-Allow-Credentials を返すか。
	AllowCredentials bool
	// Readable はブラウザが応答を読ませるか。
	Readable bool
	// Reason は判定の理由。
	Reason string
}

// Decide は、その生成元からの要求に何を返すかを決める。
//
// 仕様の要点が1つある。**`*` とクッキー付きは同時に成り立たない**。
// どこからでも読めて、しかもログイン済みの応答が読めるなら、
// 読めないことにしていた意味が消えるからだ。
func Decide(p Policy, origin string, withCookie bool) Decision {
	switch {
	case p.Wildcard && withCookie && p.Credentials:
		// ブラウザがここで止める。サーバが返しても読ませない。
		return Decision{AllowOrigin: "*", AllowCredentials: true, Readable: false,
			Reason: "* とクッキー付きは同時に使えない"}
	case p.Wildcard && withCookie:
		return Decision{AllowOrigin: "*", Readable: false,
			Reason: "クッキー付きなのに許可が * になっている"}
	case p.Wildcard:
		return Decision{AllowOrigin: "*", Readable: true, Reason: "どこからでも読める"}
	case p.Reflect:
		// 受け取った値をそのまま返すので、事実上どこからでも読める。
		// しかも Credentials を立てられるぶん、* より広い。
		return Decision{AllowOrigin: origin, AllowCredentials: p.Credentials,
			Readable: true, Reason: "Origin をそのまま返している(実質すべて許可)"}
	default:
		for _, a := range p.Allow {
			if a == origin {
				if withCookie && !p.Credentials {
					return Decision{AllowOrigin: a, Readable: false,
						Reason: "許可した相手だが、クッキー付きは許していない"}
				}
				return Decision{AllowOrigin: a, AllowCredentials: p.Credentials,
					Readable: true, Reason: "名指しで許可されている"}
			}
		}
		return Decision{Readable: false, Reason: "許可した一覧に無い"}
	}
}

// EffectivelyOpen は、その設定が実質すべての生成元に読ませるか。
//
// `Reflect` は一覧に何も書かれていなくても全開になる。設定を読んだだけでは
// 気づきにくいので、判定として名前を付けておく。
func (p Policy) EffectivelyOpen() bool { return p.Wildcard || p.Reflect }

4通りの設定に、攻撃者の生成元からクッキー付きで要求を出して測った。

設定攻撃者に返す値読めるか理由
名指し(返さない)いいえ許可した一覧に無い
**いいえクッキー付きなのに許可が *
* + クッキー許可*いいえ* とクッキー付きは同時に使えない
Origin をそのまま返すhttps://evil.test読める実質すべて許可

危ないのは最後の1つだけ、という結果になった。

* が安全側に倒れているのは、仕様に安全弁があるからだ。* とクッキー付きは同時に成り立たない。どこからでも読めて、しかもログイン済みの応答が読めるなら、読めないことにしていた意味が消える。だからブラウザが止める。サーバが両方返しても読ませない。テストで、* 単独・* + クッキー許可のどちらでも読めないことを固定した。

問題は受け取った Origin をそのまま返す実装だ。「動的に許可する」つもりで書かれることが多いが、これはすべての生成元を許可するのと同じになる。しかも * と違って安全弁が効かないので、クッキー付きで読ませてしまう。②で「攻撃者は読めない」と書いた前提が、ここで崩れる。

厄介なのは、設定を読んでも気づけないことだ。

許可した一覧の長さ実質どうか
名指し1その1つだけ
Origin 反射0すべて

一覧が空なので、見た目はいちばん厳しい。テストで、反射が「実質全開」と判定され、名指しがそうでないことを固定してある。

設計の観点

  • 生成元は3つ組で判定する: 前方一致や後方一致で書くと、似た名前のホストが通る
  • パスは境界にならない: 同じホストに置いたものは互いに読める。分けたいならホストかポートを変える
  • 送信は止まらないと覚える: 「別サイトからは呼べない」は誤り。呼べる。読めないだけだ
  • 読めなくても困らない攻撃がある: 実行が目的なら、応答は要らない
  • クッキーが自動で付く経路を数える: フォーム・画像・リンクは付く。fetch は明示したときだけ
  • Content-Type を変えるとプリフライトが増える: サーバが OPTIONS に答えないと、本番の要求が届かない
  • * は思ったより安全で、反射は思ったより危ない: 前者には安全弁があり、後者には無い
  • 設定の見た目で判断しない: 許可した一覧が空でも、実質全開のことがある

対照と実例

送信読み取りクッキー何のための仕組みか
同一生成元ポリシー(既定)通る止まる付く他サイトのデータを読ませない
CORS通る許した相手だけ通す設定次第読み取りを開ける
プリフライト新しい形だけ確かめる昔から送れたものを壊さない
CSRF 対策止める送信のほうを止める(次章)

裏どり:

  • 生成元の3つ組: スキーム・ホスト・ポート。パス・クエリ・断片は含まない。本章の判定はこの定義をそのまま実装したもので、既定ポートの補完と大文字小文字の畳み込みも入れてある
  • 単純要求の条件: メソッドが GET / HEAD / POST のいずれかで、Content-Typeapplication/x-www-form-urlencoded / multipart/form-data / text/plain のいずれか、独自ヘッダを足していないこと。この3つの Content-Type<form>enctype に対応する
  • * とクッキーの排他: Access-Control-Allow-Origin: * は、資格情報つきの要求では使えない。この規則があるので * は情報の持ち出しに直結しにくい
  • 本章が扱っていない範囲: Access-Control-Allow-Headers / -Methods / -Max-Age といったプリフライトの応答側、Vary: Origin によるキャッシュの取り違え、null 生成元の扱いは実装していない
  • 数字はすべて手元の測定: 6通り・9通り・4通りの表は、いずれもこの章の実装をテストで固定したもの

簡略化したこと

  • ブラウザを書いていない: 判定だけを実装していて、実際のブラウザに載せて確かめてはいない。仕様の要点を写した模型になる
  • やり方の一覧が代表だけ: <iframe><video>、Web フォント、postMessage、WebSocket は入れていない。WebSocket は同一生成元ポリシーの外側で、Origin を見るのはサーバの仕事になる
  • プリフライトの応答を作っていない: 要るか要らないかまでで、OPTIONS にどう答えるかは実装していない
  • キャッシュを考えていない: 生成元ごとに違う応答を返すなら Vary が要るが、扱っていない
  • null 生成元: サンドボックス化した iframefile:// から来る null は、一覧に書くと危ないことがある。ここでは扱わない
  • スキームの既定ポート: httphttps だけ入れてある

参考資料