今回はなんとなくググってヘッダーつけて終わりになっているブラウザのセキュリティーについて学習したのでアウトプットしていきたいと思います。
今回の内容はこちらの「Webブラウザセキュリティ Webアプリケーションの安全性を支える仕組みを整理する」という書籍の内容を引用しながらまとめるということをしています。
こちらの本はなんとなくで終わってしまっているWebブラウザのセキュリティーを体系的にまとめられている本で、初心者の人にも分かりやすい内容になっているので掴みの部分を理解するのにいいと思います!
URLの要素
まず、大前提URLは次のような要素で構成されています。
- スキーム (https: や http:)
- ホスト名 (foobar.example.comのfoobarの部分)
- ドメイン名 (example.com)
- ポート番号(80や443)
- パス(/index.html)
オリジンとは
オリジンとはスキーム、ホスト名+ドメイン名、ポート番号の3つの組のことです。
例) http://localhost:3000
や https://sakaishun.com
など
SOP (Same-Origin Policy)
Originを基準としてリソース間の操作を制限することです。
あるリソースから別のリソースに対する操作は次の3通りあります。
- ブラウザ内アクセス
→ ウィンドウへの参照を経由したDOMの操作など - ネットワーク越しのアクセス
→<a>
や<form>
によるページ遷移やFetchAPIによるHTTPリクエストの発行など - 埋め込み
→<iframe>
や<img>
によるページ中へのリソース埋め込みなど
これらの制限に関して表にまとめると下のようになります。
操作 | 制限 |
---|---|
ブラウザ内アクセス | ほぼ禁止 |
ネットワーク越しのアクセス | 単純リクエストの発行は許可、それ以外は禁止 |
埋め込み | 制限しない |
ブラウザ内アクセス
ブラウザ内アクセスの具体例を出すと下のようなコードです。
<script>
window.addEventListener("load", () => {
alert(window.frame01.contentWindow.secret.innerHTML == "THIS IS A SECRET MESSAGE");
alert(window.frame02.contentWindow.secret.innerHTML == "THIS IS A SECRET MESSAGE");
});
</script>
<iframe id="frame01" src="http://localhost:10000/chapter02/resource.html"></iframe>
<iframe id="frame02" src="http://localhost:20000/chapter02/resource.html"></iframe>
iframeで異なるOriginのページを表示して、それに対してDOMを書き換えて表示しようとしています。同じOriginであればできますが、Cross-Originの場合はこのようなことはできません。これができたら他のWebサイトの内容を改ざんできてしまうので、妥当なしくみだと思います。
ネットワーク越しのアクセス
ネットワーク越しのアクセスの具体例は下のようなコードです。
<form id="form01" action="http://localhost:10000/chapter02/resource.html">
<input type="hidden" name="test" value="test">
<input type="submit">
</form>
<form id="form02" action="http://localhost:20000/chapter02/resource.html">
<input type="hidden" name="test" value="test">
<input type="submit">
</form>
<p id="result01"></p>
<p id="result02"></p>
<script>
fetch("http://localhost:10000/chapter02/resource.html", { headers: { "X-CUSTOM-HEADER": "value" } }).then(() => {
document.getElementById("result01").innerText = "#1 succeeded.";
}).catch(() => {
document.getElementById("result01").innerText = "#1 failed.";
});
fetch("http://localhost:20000/chapter02/resource.html", { headers: { "X-CUSTOM-HEADER": "value" } }).then(() => {
document.getElementById("result02").innerText = "#2 succeeded.";
}).catch(() => {
document.getElementById("result02").innerText = "#2 failed.";
});
</script>
Cross-Originの場合はCORSのエラーと共にfetchが失敗しているのがわかります。しかし、formボタンでのGETはCross-Originにも関わらず成功しています。このように挙動に差が出たのは単純なリクエストかそうでないかの違いです。
単純なリクエストとは
単純なリクエストの定義はたくさんあるのですが、主にこれらです。
HTTPメソッドが以下のいずれか
- GET
- HEAD
- POST
これら以外のヘッダーを含まない
- Accept
- Accept-Language
- Content-Language
- Content-Type
Content-Typeの種類は以下のいずれか
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
これらを満たすリクエストが単純なリクエストで、それ以外は単純でないリクエストになります。fetchによるリクエストはヘッダーにX-CUSTOM-HEADER
がついており、単純なリクエストに分類されないため、SOPによりブロックされます。
ここではPOSTではないですが、formタグによってPOSTした場合デフォルトではContent-Type
はapplication/x-www-form-urlencoded
になるので、単純リクエストに該当しSOPによる制限を受けません。
一方、サーバー側がjsonしか受け付けておらず、Content-Type
をApplication/json
にしたかったり、カスタムヘッダーをつけてリクエストしたい場合も多くあるでしょう。その場合、単純でないリクエストに該当します。そのため、SOPによるブロックの対象となります。
CORS(Cross-Origin Resource Sharing)とは
あるリソースへの、本来はSOPのもとでブロックされてしまうブラウザ内アクセスやネットワーク越しのアクセスを、当該リソースの提供者が明示的に許可するための仕組みのことです。
Cross-Originの場合、カスタムヘッダーをつけてPOSTしたい場合やPUTやDELETEをしたい場合、 ブラウザに弾かれて通信することができません。
CORSではこの制限を緩和する機能がついており、ブラウザ内アクセスとネットワーク越しの単純でないリクエストのアクセスについてそれぞれ見ていきます。
ブラウザ内アクセスの許可
WebページAが異なるOriginを持つリソースBにブラウザ内でアクセスする場合、以下の条件が満たされると許可します。
- リソースBについてのレスポンス中にAccess-Control-Allow-Originヘッダーがついている
- Access-Control-Allow-Originヘッダーの値が「*」もしくはリソースAのOriginになっている
例を下に示しておきます。
# リクエスト
GET /resource.json HTTP/1.1
Host: b.example.com
Origin: http://a.example.com
略
# レスポンス
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
略
ネットワーク越しの単純でないリクエストのアクセスの許可
単純でないリクエストの場合、いったん発行せずに以下のヘッダを持ったプリフライトを発行します。
- リクエスト発行元のOriginを値に持つOriginヘッダー
- 発行したいリクエストのメソッドを値として持つAccess-Control-Request−Methodヘッダー
- 発行したいリクエストに付与したいヘッダー名のリストを値として持つAccess-Control-Request-Headersヘッダー
プリフライトによるリクエストへのレスポンスが以下のすべてを満たしている場合、もともと発行する予定だったリクエストを発行します。なお、満たしていない場合はエラーを起こします。
- レスポンス中のAccess-Control-Allow-Originヘッダーの値がリクエスト元のOriginを含んでいる
- レスポンス中のAccess-Control-Allow-Methodsヘッダーの値が、もともと発行したかったリクエストのメソッドを含んでいる
- レスポンス中のAccess-Control-Allow-Headersヘッダーの値がもともと発行したかったヘッダーをすべて含んでいる
実際の例を示しておきます。
# プリフライト リクエスト
Request URL: https://submane-server.herokuapp.com/users
Request Method: OPTIONS
Status Code: 204 No Content
Remote Address: **********:443
Referrer Policy: strict-origin-when-cross-origin
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
# ------------------------------------------------------------------------------------------------
Access-Control-Request-Headers: authorization,content-type
Access-Control-Request-Method: POST
# ------------------------------------------------------------------------------------------------
Connection: keep-alive
Host: submane-server.herokuapp.com
Origin: https://submane-ui.vercel.app
Referer: https://submane-ui.vercel.app/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site
# プリフライト レスポンス
Access-Control-Allow-Credentials: true
# ------------------------------------------------------------------------------------------------
Access-Control-Allow-Headers: authorization,content-type
Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
Access-Control-Allow-Origin: https://submane-ui.vercel.app
# ------------------------------------------------------------------------------------------------
Allow: OPTIONS, GET, POST
Connection: keep-alive
Content-Length: 0
Date: Sat, 06 Aug 2022 00:27:15 GMT
Server: Cowboy
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Via: 1.1 vegur
これは実際のSPAでつくったアプリのレスポンスのやりとりを載せました。上で示した条件を満たしているのが分かります。すると、ブラウザはもともと発行したかったリクエストを発行します。
# もともと発行したかったリクエスト
Request URL: https://submane-server.herokuapp.com/users
Request Method: POST
Status Code: 201 Created
Remote Address: 52.5.82.174:443
Referrer Policy: strict-origin-when-cross-origin
Accept: application/json, text/plain, */*
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Authorization: Bearer **********************
Connection: keep-alive
Content-Length: 66
Content-Type: application/json
Host: submane-server.herokuapp.com
Origin: https://submane-ui.vercel.app
Referer: https://submane-ui.vercel.app/
sec-ch-ua: ".Not/A)Brand";v="99", "Google Chrome";v="103", "Chromium";v="103"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site
# レスポンス
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://submane-ui.vercel.app
Connection: keep-alive
Content-Length: 246
Content-Type: application/json; charset=UTF-8
Date: Sat, 06 Aug 2022 00:27:16 GMT
Server: Cowboy
Set-Cookie: userId=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NTk4MzIwMzYsInN1YiI6IjQifQ.rQ1aVr8-r-xN8ZCyeXchrOXPayWTna9thOtaNxFH6dM; Path=/; Expires=Sun, 07 Aug 2022 00:27:16 GMT; HttpOnly; Secure; SameSite=None
Vary: Origin
Via: 1.1 vegur
認証情報が付与されたリクエストに対する制限
外部APIへのアクセスではクッキーやBasic認証情報など認証情報を載せてリクエストを行うことが多いです。Cross-Originなリクエストの場合、クッキーは付与されません。クッキーが付与された状態でCross-Originなリクエストを行う場合、次のような設定を行っている必要があります。
- Access-Control-Allow-Originヘッダーが「*」でなくリクエストのOriginヘッダーで指定された値を含んでいること
- レスポンスがAccess-Control-Allow-Credentials: trueヘッダーを含んでいること
SPAでの例で示したアクセスではクッキーが送信されていますが、これはこの設定を行っているからです。
さいごに
なんとなくライブラリの設定をして終わってしまっているCORSの学習をするするのにいい本だと思ったのでぜひ買って読んでみてください。
[…] ブラウザのセキュリティについて 今回はなんとなくググってヘッダーつけ… […]