CORSの仕組み
文書はhttp://127.0.0.1:4322、APIは同じホストの4323番で動きます。GETの許可なし・許可あり、応答ヘッダーの公開、same-origin/no-corsの指定を比較し、最後に許可のないPOSTがサーバーへ届くか確認します。URLのpolicyは返すCORS設定、tagは記録を探すための名前です。
説明
Web文書のオリジンはscheme・host・portの組合せで決まります。別オリジンへのFetchでは、サーバーがCORSヘッダーで応答の公開を許可する必要があります。ただし、読取りが拒否されたことと、要求自体が送信されなかったことは同じではありません。
文書はhttp://127.0.0.1:4322、APIは同じホストの4323番で動きます。GETの許可なし・許可あり、応答ヘッダーの公開、same-origin/no-corsの指定を比較し、最後に許可のないPOSTがサーバーへ届くか確認します。URLのpolicyは返すCORS設定、tagは記録を探すための名前です。
1二つのオリジン
文書とAPIのURLを分解します。
(globalThis.api="http://127.0.0.1:4323",{documentOrigin:location.origin文書のオリジン,apiOrigin:new URL(api).originAPIのオリジン,same:location.origin===一致を比較new URL(api).origin})hostが同じでもportが4322と4323で異なるため、同じオリジンではありません。パスの違いではなく、この組合せがFetchのCORS判定に関わります。
2公開許可のないGET
Access-Control-Allow-Originを返さないAPIへGETします。
await fetch(api+"/api?policy=deny公開許可を返さない応答&tag=get-deny要求の記録名").then(r=>({status:r.status}),e=>({error:e.name読取り失敗の型}))FetchはTypeErrorで拒否されます。この結果だけでは要求が送られなかったとは判断できないため、次にサーバー側の受信記録を見ます。
3要求はサーバーに届いている
get-denyの受信記録を取得します。
await(await fetch("/records?tag=get-deny")同一オリジン経由でサーバー記録を取得).json()GETとOriginが記録されています。事前確認を要しないこの要求は送信され、応答の公開段階で拒否されました。CORSを「他オリジンからの要求を一切通さない仕組み」と捉えると、更新操作などの保護を誤ります。
4指定オリジンへ応答を公開する
APIが文書のオリジンを許可した場合の応答を読みます。
(globalThis.allowed=await fetch(api+"/api?policy=exact文書のオリジンを許可&tag=get-exact"),{status:allowed.status,type:allowed.type応答の種類,body:await allowed.json(),contentType:allowed.headers.get("Content-Type"),revision:allowed.headers.get("X-Revision")独自ヘッダーの公開状態})応答本文を読めるようになり、typeはcorsです。ただしX-Revisionはnullです。別オリジン応答でスクリプトへ公開されるヘッダーには制限があり、Content-Typeのように既定で公開されるものと追加公開が必要なものがあります。
5独自ヘッダーも公開する
Access-Control-Expose-HeadersにX-Revisionを加えた応答を読みます。
(globalThis.exposed=await fetch(api+"/api?policy=exposeX-Revisionを追加公開&tag=get-expose"),{revision:exposed.headers.get公開された値を取得("X-Revision"),body:await exposed.json()})今度はX-Revisionの7を取得できます。Access-Control-Allow-Headersは要求ヘッダーの許可、Expose-Headersは応答ヘッダーの公開で、方向と目的が異なります。
6ワイルドカードの公開
Cookieを含めない要求で、Allow-Originが*の応答を取得します。
(globalThis.publicResponse=await fetch(api+"/api?policy=wildAllow-Originを*にする&tag=public",{credentials:"omit"資格情報を含めない}),{status:publicResponse.status,body:await publicResponse.json()})この要求では応答を取得できます。*は任意のオリジンへの公開ですが、credentials:includeの応答を公開する許可とは両立しません。公開データと資格情報を使う応答では設定条件が変わります。
7same-originモードで別オリジンを拒否する
要求のmodeをsame-originへ変えます。
await fetch(api+"/api?policy=exact&tag=same-only",{mode:"same-origin"同じオリジンの宛先だけに限定}).then(r=>({status:r.status}),e=>({error:e.name}))別オリジンなのでFetchが失敗します。このモードは要求の宛先を同じオリジンに限定します。サーバーのAllow-Originが一致していても、この制約を解除することはできません。
8送られなかったことを記録で確かめる
same-onlyの受信記録を取得します。
await(await fetch("/records?tag=same-only対象要求の受信有無を確認")).json()記録は空です。先ほどの許可なしGETとは異なり、この要求は送信前に拒否されました。クライアント側の例外型が同じでも、通信が進んだ範囲は異なります。
9no-corsは本文読取りの回避策にならない
許可のないAPIへno-corsでGETします。
(globalThis.opaque=await fetch(api+"/api?policy=deny&tag=opaque",{mode:"no-cors"opaque応答になる要求}),{type:opaque.type,status:opaque.status公開される状態値,ok:opaque.ok,headers:[...opaque.headers],text:await opaque.text()公開本文を取得})応答はopaqueで、statusは0、公開ヘッダーは空、本文も読めません。0はサーバーがHTTP 0を返したという意味ではなく、応答情報がスクリプトから隠されていることを表します。no-corsへ変更すればJSONを読める、という対処にはなりません。
10許可なしのフォーム形式POST
フォーム形式の本文を、CORSの公開許可なしで送ります。
await fetch(api+"/api?policy=deny&tag=form-post",{method:"POST"POST要求,body:new URLSearchParamsフォーム形式の本文({qty:"2"})}).then(r=>({status:r.status}),e=>({error:e.name}))応答の読取りは失敗します。このような条件のPOSTはプリフライトなしで送信できるため、読取り拒否だけで更新処理を防げるとは限りません。
11POST本文も届いている
サーバーが受け取ったmethodと本文を表示します。
(await(await fetch("/records?tag=form-postPOSTの記録を取得")).json()).map(r=>({method:r.method受信メソッド,origin:r.origin,body:r.body受信本文}))POSTとqty=2が記録されています。サーバーは応答を公開するかだけでなく、操作そのものを誰に許可するか、要求元の条件をどう検証するかを別に決める必要があります。
まとめ
CORSによる応答公開の拒否と、要求の送信拒否は同じではありません。
Allow-OriginとExpose-Headersにより、本文と独自応答ヘッダーの読取り条件が変わります。
same-originとno-corsの効果は異なり、no-corsは任意の応答を読めるようにする指定ではありません。
操作履歴と解説を振り返れます。
ブラウザ / オリジン・通信記録
(globalThis.api="http://127.0.0.1:4323",{documentOrigin:location.origin,apiOrigin:new URL(api).origin,same:location.origin===new URL(api).origin}){
"documentOrigin": "http://127.0.0.1:4322",
"apiOrigin": "http://127.0.0.1:4323",
"same": false
}await fetch(api+"/api?policy=deny&tag=get-deny").then(r=>({status:r.status}),e=>({error:e.name})){
"error": "TypeError"
}await(await fetch("/records?tag=get-deny")).json()[
{
"tag": "get-deny",
"method": "GET",
"origin": "http://127.0.0.1:4322",
"requestMethod": "",
"requestHeaders": "",
"cookie": "",
"body": ""
}
](globalThis.allowed=await fetch(api+"/api?policy=exact&tag=get-exact"),{status:allowed.status,type:allowed.type,body:await allowed.json(),contentType:allowed.headers.get("Content-Type"),revision:allowed.headers.get("X-Revision")}){
"status": 200,
"type": "cors",
"body": {
"accepted": true,
"method": "GET",
"cookie": "",
"body": ""
},
"contentType": "application/json",
"revision": null
}(globalThis.exposed=await fetch(api+"/api?policy=expose&tag=get-expose"),{revision:exposed.headers.get("X-Revision"),body:await exposed.json()}){
"revision": "7",
"body": {
"accepted": true,
"method": "GET",
"cookie": "",
"body": ""
}
}(globalThis.publicResponse=await fetch(api+"/api?policy=wild&tag=public",{credentials:"omit"}),{status:publicResponse.status,body:await publicResponse.json()}){
"status": 200,
"body": {
"accepted": true,
"method": "GET",
"cookie": "",
"body": ""
}
}await fetch(api+"/api?policy=exact&tag=same-only",{mode:"same-origin"}).then(r=>({status:r.status}),e=>({error:e.name})){
"error": "TypeError"
}await(await fetch("/records?tag=same-only")).json()[]
(globalThis.opaque=await fetch(api+"/api?policy=deny&tag=opaque",{mode:"no-cors"}),{type:opaque.type,status:opaque.status,ok:opaque.ok,headers:[...opaque.headers],text:await opaque.text()}){
"type": "opaque",
"status": 0,
"ok": false,
"headers": [],
"text": ""
}await fetch(api+"/api?policy=deny&tag=form-post",{method:"POST",body:new URLSearchParams({qty:"2"})}).then(r=>({status:r.status}),e=>({error:e.name})){
"error": "TypeError"
}(await(await fetch("/records?tag=form-post")).json()).map(r=>({method:r.method,origin:r.origin,body:r.body}))[
{
"method": "POST",
"origin": "http://127.0.0.1:4322",
"body": "qty=2"
}
]演習完了