送れるのに読めない(同一生成元と CORS)
実装:
crypto/sameorigin// 実行:go test ./crypto/sameorigin/
別のサイトへは送れる。返ってきたものが読めないだけだ。名前が「同一生成元でないと駄目」に見えるので、逆に覚えられていることが多い。6通りのやり方を並べて測ると、送れるのが6、読めるのが0になった。クッキーも付いて飛ぶ。CORS は読めないほうを開ける仕組みで、開け方を間違えると許可の一覧が空のまま全部開く。
この章で作るもの
ブラウザが別のサイトを触るときの決まりを実装して、何が通って何が止まるかを測る。
まず言葉を1つ。生成元(オリジン)というのは、スキーム・ホスト・ポートの3つ組のことだ。https://example.com と http://example.com は別、example.com と api.example.com も別になる。この3つ組が一致するときだけ「同じ生成元」だ。
そして同一生成元ポリシーは、名前から想像するものと中身が違う。別の生成元へ行くこと自体は止めない。止めるのは、返ってきたものを読むことだけだ。
あなたのページ 別のサイト
(https://evil.test) (https://bank.example)
<form action=…> ────── 送信 ────────▶ 受け取る・処理する
<img src=…> ────── 送信 ────────▶ ログに残る
fetch(…) ────── 送信 ────────▶ 実行される
│
JavaScript ◀───── 応答 ─────────────┘
✗ 読めない (ブラウザが握りつぶす)
送るのは通る。読むところで止まる。
クッキーも付いて飛ぶ ── ここが CSRF の足場になる順に見ていく。
- 生成元は3つ組で、パスは入らない: 同じホストの別ページは、互いに同じ生成元になる
- 止まるのは読み取りだけで、送信は通る: 6通りで測ると、送れるのが6、読めるのが0
- 問い合わせが要る境目は、フォームで送れた範囲で決まっている: JSON が境目をまたぐ
- 開け方を間違えると、一覧が空のまま全開になる: 設定を読んでも気づけない
① 生成元は3つ組で、パスは入らない
まず判定を作る。
// 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つ組の完全一致でなければならない理由がここにある。
② 止まるのは読み取りだけで、送信は通る
ここがこの章の中心になる。やり方ごとに、送れるか・読めるか・クッキーが付くかを並べた。
// 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 は同一生成元ポリシーより後にできたので、最初から安全側の既定を選べた。付けたければ明示する。古いものは既定が緩く、新しいものは既定が厳しい、という差がそのまま残っている。
動かす
下のデモは、やり方と生成元を選んで、何が通るかを見比べる。
| やり方 | 送れる | 読める | クッキー |
|---|---|---|---|
| <form> の送信 | 通る | 読めない | 付く |
| <img src> | 通る | 読めない | 付く |
| <script src> | 通る | 読めない | 付く |
| リンクを踏む | 通る | 読めない | 付く |
| fetch() | 通る | 読めない | 付かない |
| fetch(…, {credentials:"include"}) | 通る | 読めない | 付く |
同一生成元ポリシーは「別のサイトへ行かせない」仕組みではない。行けるし、届くし、クッキーも付く。 応答をスクリプトから読ませないだけだ。CORS はその読み取りを開ける仕組みで、送信のほうは別に止める必要がある。
③ 問い合わせが要る境目は、フォームで送れた範囲で決まっている
②で「読み取りだけを止めた」と書いたが、実際には送信の側にも一部だけ制限が入っている。単純要求に当てはまらない要求は、本番の前にブラウザが OPTIONS を投げて許可を確かめる。これがプリフライトだ。
どこで線が引かれているか。
// 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> で送れたものだ。フォームのメソッドは GET と POST しかなく、enctype は3種類しかない。つまり昔から送れていたものは、いまも問い合わせなしで送れる。それ以外は新しいので、確かめてから送れる。
だからここでも②と同じ理屈が効いている。既に送れていたものは止められない。止められるのは、後から増えたものだけになる。
実務でこれが顔を出すのは、Content-Type を application/json にした瞬間だ。同じ POST なのに、突然 OPTIONS が飛ぶようになる。サーバ側が OPTIONS に答えないと、本番の要求が一度も届かない。「サーバのログに何も来ていないのに失敗する」という形で出る。
そして裏返すと、JSON にしておくとプリフライトが挟まるぶん、フォーム形式より CSRF が起きにくい。攻撃者のページからは OPTIONS に答えてもらえないからだ。ただしこれは副産物であって、対策として頼るものではない。
④ 開け方を間違えると、一覧が空のまま全開になる
読めないものを読ませるのが CORS だ。サーバが「この生成元には読ませてよい」と返す。
// 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-Typeがapplication/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生成元: サンドボックス化したiframeやfile://から来るnullは、一覧に書くと危ないことがある。ここでは扱わない- スキームの既定ポート:
httpとhttpsだけ入れてある
参考資料
- 同一生成元ポリシー(MDN) — 3つ組の定義と、何が制限されるか
- CORS(MDN) — 単純要求の条件、プリフライト、資格情報つきの規則
- 次の章: 送れることを使う攻撃(CSRF)
- 実装: crypto/sameorigin