初投稿です
若輩ですがよろしくお願いいたしまする。
そもそもCORSって?
CORSについての説明は他の方々も様々な角度でしてくださっているので、基本的には割愛します。
Qiitaにもわかりやすく解説してくださっている記事が複数あるので、そちらを参照ください。
なんとなく CORS がわかる...はもう終わりにする。
CORSについてざっくり理解する
一言でいうなら、
「セキュリティを考慮しつつSOPの制限を回避する、ブラウザの仕組み」
です。
で、その仕組みとして、異なるオリジンのホストへリクエストを飛ばすときにはCORSの仕組みが働いて、Access-Control-Allow-OriginだのAccess-Control-Allow-Headerだのがそのホストからのレスポンスに設定されていないと、リソースを利用することができないというわけです。
大事なのは
- SOPの制限と共存していくために編み出された仕組みだということ
- XSSやCSRFの対策として考案された仕組みではないということ
- ブラウザの仕組みであること
の3点だと思っています。
SOPってなんだっけ?
CORSの元になっているSOP(Same-Origin Policy)という仕組み、こいつについても簡単に記載すると、
「異なるオリジンのリソースへのJavaScriptでのアクセスを禁止する機構」
です。
なんでこんな仕組みが必要なの?という疑問に対しては、以下のリンクで納得しました。
【初心者向け】SOP・CORSの必要性
包括的にSame-Origin Policy(同一生成元ポリシー)を理解する<2021年版>
SOPが無いと、外部に公開したくないはずの情報が悪意あるJavaScriptによって簡単に読み出されてしまい得るということですね。そんな世界を想像すると、SOPの必要性にも納得です。
何を理解しかったか
先ほど書いた通り、CORSはXSSやCSRFの対策として考案されたものではないですが、XSSやCSRFの保険的な対策の一つとして挙げられることが往々にしてあります。
私自身何度かそういう言論に触れたことがあるのですが、そのたびにボンヤリとした納得具合で受け流してきたので、今回簡単なサーバーを立てて改めてCORSってどんな仕組みなのか検証することで、CORSがどこまでセキュリティ上の対策になりうるのかを腹落ちさせたいと思いました。
用意した環境
flaskをフレームワークとして極々単純なサーバーを2台立てました。
また、BurpSuiteを使って、各サーバーのhttpリクエスト・レスポンスの内容を観察しています。
(BurpSuiteの使い方もWebにたくさん出回っているので割愛。)
localhost:8888
こちらのサーバーがレスポンス担当です。
dir1/
├ app/
├ templates
├index.html
├cors.html
├csrf.html
├ views.py
├ main.py
├ .venv
import os
from app.views import app
if __name__ == '__main__':
app.run(os.getenv('APP_ADDRESS','localhost'), port=8888)
import os
from flask import Flask, render_template, request, abort
app = Flask(__name__)
@app.route('/', methods=["GET", "POST"])
def index():
return render_template('index.html')
@app.route('/cors', methods=["GET","POST"])
def cors():
return render_template('cors.html')
#CSRF検証で使います
@app.route('/csrf/<val>', methods=["POST"])
def csrf(val):
value = val
return render_template('csrf.html', value=value)
@app.after_request
def after_request(response):
#response.headers['Access-Control-Allow-Origin'] = "http://localhost:9999"
#response.headers['Access-Control-Allow-Headers'] = "my-header"
return response
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
cors!!!!
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!--CSRFの検証で使います-->
{{value}}
</body>
</html>
localhost:9999
こちらのサーバーがリクエスト担当です。
サーバーを起動させて、localhost:9999/formにブラウザからアクセスすると、味気ないですが以下のような画面になります。
「CORS1_POST」を押すとCross-OriginのPOSTリクエストが、「CORS2_GET」を押すとCross-OriginのGETリクエストが、「CORS3_NO_CORS」を押すとmode="no_cors"としたfetchAPIによるCross-OriginのGETリクエストが飛ばせます。
また、「form_submit」では、formタグを利用したCross-Originリクエストが飛ばせます。
dir2/
├ app/
├ templates
├base.html
├form.html
├ views.py
├ main.py
├ .venv
import os
from app.views import app
if __name__ == '__main__':
app.run(os.getenv('APP_ADDRESS','localhost'), port=9999)
import os
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/form')
def index():
return render_template('form.html')
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
{%block content%}
{%endblock%}
</body>
</html>
{% extends "base.html" %}
{%block content%}
<button onclick="cors1()">CORS1_POST</button>
<button onclick="cors2()">CORS2_GET</button>
<button onclick="cors3()">CORS3_NO_CORS</button>
<form action="http://localhost:8888/cors" method="POST">
<input type="submit" value="form_submit">
</form>
<a href="http://localhost:8888/cors">aタグ</a>
<script>
let url = "http://localhost:8888/cors"
const param1 = {
method:"POST",
//ここにCORS単純リクエスト定義外のヘッダをセットするとプリフライトが飛ぶ
headers:{
}
}
const param2 = {
method:"GET",
//ここにCORS単純リクエスト定義外のヘッダをセットするとプリフライトが飛ぶ
headers:{
}
}
const param3={
mode: "no-cors"
}
//POSTメソッドによるfetchの場合
function cors1(){
fetch(url, param1)
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
document.body.appendChild(doc.documentElement);
})
.catch(console.error);
}
//GETメソッドによるfetchの場合
function cors2(){
fetch(url)
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
document.body.appendChild(doc.documentElement);
})
.catch(console.error);
}
//fetchをno-corsモードにした場合
function cors3(){
fetch(url, param3)
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
document.body.appendChild(doc.documentElement);
})
.catch(console.error);
}
</script>
{%endblock%}
CORSの検証
先立ってCORSの動作について少し詳しめに見ていきます。
結論から言うと、
リクエストが飛ぶかどうかは、「そのリクエストが単純リクエストであるかどうか」に依存します。単純リクエストである場合にはプリフライトリクエストが飛ぶことなく、即座にリクエストがサーバーへ飛びます。
リクエストによって得られたリソースはCORSによって許可されていない限り、ブラウザから拒否されます。(単純リクエストであるかどうかによらず必ずブラウザに拒否されます)
POST単純リクエストの場合
まず「CORS1_POST」をクリックします。
その際のBurpSuite上でのHTTP通信のHistoryは以下です。
単純リクエストの条件を満たしているので、プリフライトリクエストが飛ぶことなく、直接サーバーにPOSTリクエストが飛んでいるのが分かるかと思います。
次に、ブラウザのDevToolを見てみます。
HTTP通信上は正常にリソースが取得されていたにもかかわらず、ブラウザ上ではCORSエラーが発生していました。
つまり、クロスドメインから取得したリソースをブラウザで利用することには失敗したことになります。
単純リクエストの場合にはプリフライトリクエストが発生しないので、最終的にCORSエラーになるとしても、サーバーにリクエスト自体は飛んでしまうのが特徴です。
GET単純リクエストの場合
GET単純リクエストの場合は、POST単純リクエストと同様です。HTTP通信は正常にやりとりされますが、ブラウザでの処理でCORSエラーが発生しました。
POSTリクエストにユーザー設定のヘッダを付与した場合
次に、POSTのパラメータにユーザー設定のヘッダを付与して、単純リクエストの条件を満たさなくなった場合にどうなるかを見てみます。
localhost:9999のform.htmlのscriptタグ内、param1を以下のように修正します。
const param1 = {
method:"POST",
//ここにCORS単純リクエスト定義外のヘッダをセットするとプリフライトが飛ぶ
headers:{
"my-header":"test" //追加
}
}
HTTP通信を見ると、単純リクエストの際には見られなかったOPTIONメソッドの通信が飛んでいるのに気づきます。これが所謂プリフライトリクエストです。
CORSが有効であるとき、ブラウザは自動でOriginヘッダをプリフライトリクエストに付与します。
サーバー側でCORSが適切にセットされていれば、Access-Control-Allow-Originヘッダ等が返ってくるはずになります。返ってきた場合、ブラウザでこのヘッダが検証され、問題なければ本当のリクエストを送るという流れになります(後で設定した場合も検証します)。
今回はCORSが設定されていないので、プリフライトリクエストの検証に失敗し、後続のリクエストは送られなかったということになります。
GETリクエストにユーザー設定のヘッダを付与した場合
POSTリクエストにユーザー設定のヘッダを付与した場合と同様なので省略!
CORSを適切に設定する
CORSエラーを解消するために、localhost:8888に対して、設定を施します。
localhost:8888のviews.py内、after_request()でコメントアウトしておいた行を有効にします。
def after_request(response):
response.headers['Access-Control-Allow-Origin'] = "http://localhost:9999"
response.headers['Access-Control-Allow-Headers'] = "my-header"
return response
上記設定をした状態で、クロスオリジンのリクエストがどうなるのか、GETもPOSTもほぼ同じですので、代表してPOSTを検証することにします。
以下が、「CORS1_POST」をクリックした際のHTTP通信です。
プリフライトリクエストであるOPTIONメソッドが飛んだあと、本番のPOSTリクエストが飛んでいるのが分かるかと思います。
CORS設定によって、プリフライトリクエストのレスポンスヘッダにAccess-Conrol-Allow-OriginとAccess-Control-Allow-Headersがセットされ、検証をクリアした結果、本番のPOSTリクエストを飛ばすことができました。
そしてその結果としてlocalhost:9999/formは以下のようになりました。
CORSの適切な設定によりクロスオリジンのリソースが正常に取得され、ブラウザ上に問題なく反映されたことが確認できました。
formタグ・aタグでリクエストを飛ばす場合
若干蛇足気味ですが、こちらも見ておきます。
サーバーへのリクエストを送る手段はfetchやXMLHttpRequestだけではありません。formタグやaタグでも飛ばすことができます。
(他ここでは検証しないですが、JSONPで用いられるscriptタグとかは、SOPを回避する目的でCORSが無い時代に用いられていたことで有名です。JSONPの仕組みはこちら参考: 沈思黙考:jsonpの仕組み)
これらのタグの挙動がどうなのかも確認します。※挙動を見る際には、CORSの設定はまたコメントアウトにしました。CORS制限が有効に働くのであれば、これでリクエスト結果がブラウザに到達することはないはずです。
@app.after_request
def after_request(response):
#response.headers['Access-Control-Allow-Origin'] = "http://localhost:9999"
#response.headers['Access-Control-Allow-Headers'] = "my-header"
return response
formタグでリクエストを飛ばす場合
form_submitボタンをクリックします。
すると特にCORSに引っかかることなくリソースページへ遷移することができました。formタグのリクエストはCORS制限の対象外であることが分かります(ほぼ同様なので、BurpSuiteの画像は省略します)
aタグでリクエストを飛ばす場合
formタグと同様です。
CORSに引っかかることなく、リソースページへ遷移することができました。こちらもCORS制限の対象外です。
(画像略っ)
(おまけ)fetchでmode="no_cors"としてCORS設定を意図的に無効にした場合
じゃあfetchのmodeをno_corsにしちゃったらCORSエラー解決じゃん!という浅はかな発想のもと、mode="no_cors"についても検証しましたが、CORSがどうのこうのというよりはfetchの仕様の話なので、詳しくは割愛します。
結果としては以下のリンクで詳しく書いてある通りなのですが、no_corsを使うとfetchの結果CORSエラーになることはないですが、取得したリソースには一切アクセスできないという制限が付き、本末転倒状態になってしまいます。
Fetch APIの no-cors モードについて
CORSは分かった。じゃあCORSはCSRF, XSSに役立つの?
以上でCORSに関する基本的な検証は終了です。
ここからはCSRF, XSSに対してCORSが対策になっているのかどうか、自分の考えも含めながら書いていきたいと思います。
CSRFに対するCORSのお役立ち度
結論:がんばったら効果あり
CORSの検証の際に見た通り、単純リクエストの場合にはCORSが働いていようが問答無用でリクエストはサーバーに飛びます。レスポンスがブラウザ上で拒否されるだけです。
リクエストがサーバーに飛んでしまうと、サーバーで処理自体は実行されてしまうのでCSRF対策にはなりません。
じゃあプリフライトリクエストが飛ぶようにすればいいのですが、ただヘッダを設定して飛ばせるようにしたのでは対策としては不十分です。なぜなら単純リクエストが飛ぶようにしたfetchやFormタグやaタグを攻撃者に利用されてしまえばそもそもプリフライトリクエストを回避されてしまうからです。
そこでリクエストにCSRF対策用ヘッダを設定します。そうすることでプリフライトリクエストが毎回飛ぶようにします。
そしてCSRF対策ヘッダが付与されているかをサーバー側でチェックするようにして、付与されていない場合には拒否するようにします。そうすることで、CSRF対策用ヘッダをセットしていないfetchやForm、aタグを利用した不正なリクエストは拒否されるようになり、CORSを利用したCSRF対策が可能になります。
(ただこの対策は、リソースを取得する側と取得される側で仕様を同意したうえで、ソースコード本体に手を加える必要があるので、現場でやろうとすると結構体力がいる気がします。普通にメジャーなCSRF対策をとる方が楽です。)
一応検証します
localhost:7777という攻撃者サーバー想定のものを新たに立てました。
非常に簡易ですが、このcsrfform.htmlから発火できるリクエストを、CSRF攻撃のリクエストであると想定しています。
(なお、localhost:8888で設定したCORSのヘッダは、再度有効にしておきます。)
localhost:7777
こちらのサーバーが攻撃者サーバー想定です。
dir3/
├ app/
├ templates
├base.html
├csrfform.html
├ views.py
├ main.py
├ .venv
import os
from app.views import app
if __name__ == '__main__':
app.run(os.getenv('APP_ADDRESS','localhost'), port=7777)
import os
from flask import Flask, render_template
app = Flask(__name__)
@app.route('/csrfform')
def csrfform():
return render_template('csrfform.html')
{% extends "base.html" %}
{%block content%}
<button onclick="csrf()">CSRF</button>
<form action="http://localhost:8888/csrf/abc" method="POST">
<input type="submit" value="form_submit">
</form>
<script>
let url = "http://localhost:8888/csrf/abc"
//ヘッダも何もセットしていないシンプルリクエストなので、プリフライトリクエストが飛ばずに直でサーバーにリクエストが行く
const param = {
method:"POST",
headers:{
}
}
//CORSボタンを押したPOSTメソッドによるfetch
function csrf(){
fetch(url, param)
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
document.body.appendChild(doc.documentElement);
})
.catch(console.error);
}
</script>
{%endblock%}
さて、この状態でlocalhost:7777/csrfformにアクセスすると以下のような簡素な画面になります。
それではCSRFボタンを押してみます。CORSが設定されているのでブラウザにレンダリングされることはありません。ただし、Burpで通信を確認してみると、サーバーでリクエストが処理された結果のリソースが返却されているのが見られます。
これはCSRF攻撃が成功していることを意味しています。
CORSはCSRF対策を想定した仕組みではないので、ある意味当然なのですが、CORSの設定をしているだけではCSRFは防げないということを示しています。
form_submitボタンを押しても同様に成功します。
この場合は、先ほど検証した通りCORS制限対象外なので、プリフライトリクエストがそもそも飛びません。結果は同様なので画像は省略です。
それでは、localhost:8888(レスポンスを返すサーバー)側で、ロジックの修正をします。
"my-header"がリクエストヘッダに含まれない場合には、リクエストを拒否するようにします。
@app.route('/csrf/<val>', methods=["POST"])
def csrf(val):
#ここを追加
if "my-header" not in request.headers:
abort(400)
value = val
return render_template('csrf.html', value=value)
それでは再度localhost:7777のCSRFボタンを押してみます。ブラウザ側の処理は、修正前後で変わらずCORS機構によってレンダリングが拒否されているのですが、変化がみられるのはBurp側になります。
サーバー側で行われた処理がabortされるので、CSRF攻撃によるサーバー処理を終了させることができているのが確認できます。
これによりCSRF攻撃を防ぐことができました。
なお、my-headerをセットすればこのバリデーションを回避できるじゃないか!という疑問が一瞬浮かぶかもしれませんが、この場合はプリフライトリクエストにより拒否されるので、同様にCSRFには失敗します。
XSSに対するCORSのお役立ち度
結論:ほぼ無い気がする
そもそもXSS自体がSOPをかいくぐって標的サーバーの情報を窃取しようとするものになるので、CORSの仕様によってXSSが防げるということはほぼ無いと思われます。
SOP・CORSによる制限を回避して攻撃対象のドメインにあるリソースが取得したいがために、攻撃対象ドメインにXSS攻撃を仕掛けるのであるのだから、XSSをCORSで防げるわけがないですね。。
あり得るかなぁと思いついたのはXSSで仕込まれてしまったajaxやfetchといった非同期通信APIによって、プライベートネットワーク内にある別ドメインのサーバーに対してリクエストを攻撃者が飛ばすようなケースでしょうか。踏み台サーバー的な使われ方をしてしまうのを防げる気がします。(正確なとこ分かってないです、違ったらすいません。)
Qiitaに限らずいろんなところでCORSがXSS対策になると書いている記事がたくさん(しかもその中にはかなり高評価を得ている記事も・・・)見受けられたのですが、実際にどんな効果があるのか、あるなら教えてほしい・・・。CORSがXSS対策になると書いているほぼすべての記事が、CORS=XSS対策と機械的に(しかも間違って)覚えているだけのような気がしました。
結論
CORSやSOPを調べている中で以下のコメントを見つけました。
「CORSの必要性」「Same-Origin Policy」について、XSS/CSRF対策のどちらもほとんど重要ではないと思います。というコメントです。
この記事を書いていく中で見つけて、読んでいてなるほどなぁと思いました。
CORSの仕組み自体はセキュリティを保ちつつWebの利便性を高めることができる仕組みであろうと思いますが、XSS/CSRFの対策のために誕生したものではなく、そこを過信しない、CORSはCORSとしてあるべき設定を行うのがセキュリティを考える上では正しいんじゃないかと思いました(Access-Control-Allow-Origin: "*"には安易にしない、とか)。
CORSがあるからといってそれ単体で脆弱性が防げるものではなく、各脆弱性の対策はしっかりと検討していく必要がありますね。