認証情報付きの CORS(Cross-Origin Resource Sharing)リクエストを成功させる
tl;dr
CORS の設定が不適切である場合、セキュリティ上の脆弱性を生む可能性があります。
CORS について学びたい場合は、PortSwagger の学習コース がオススメです。
CORS(Cross-Origin Resource Sharing)とは
CORS は、ブラウザが異なるオリジンからリソースをリクエストできるようにするための仕組みです。
以下のようなヘッダーを利用して、異なるオリジンからのリクエストを許可するかどうかを制御します。
各ヘッダーに設定できる具体的な値については、オリジン間リソース共有 (CORS) - HTTP | MDN を参照してください。
レスポンスに含まれるヘッダー
- Access-Control-Allow-Origin
- リクエストを許可するオリジン
- Access-Control-Allow-Method
- リクエストを許可するメソッド
- Access-Control-Allow-Headers
- リクエストを許可するヘッダー
- Access-Control-Allow-Credentials
- 資格情報(クッキーなど)を含むリクエストを許可するかどうか
リクエストに含まれるヘッダー
- Access-Control-Request-Headers
- リクエストで送信するヘッダー
- Access-Control-Request-Method
- リクエストで使用するメソッド
CORS が必要な理由
もし CORS が存在せず、異なるオリジンからの全てのリクエストを許可する仕様な場合[1]、セキュリティ的に非常に望ましくない状況になります。
例えば、認証情報をクッキーで利用するウェブサービスを提供する場合において、如何なるドメインで実行される悪意あるスクリプトからでも、その認証情報を利用してウェブサービスにアクセスすることができるということです。
もう少し具体的に言えば、銀行のウェブサービスを考えると、悪意あるスクリプトが配置されたウェブサイトを開くだけで、その銀行のウェブサービスに対して任意の操作を行うことができるということです。
CORS リクエストを成功させる
認証を要する Cross-Origin なリクエストを成功させるための CORS の設定を段階を踏みつつ見ていきます。
実験用に以下のようなコードを用意しました。以下のコードをベースとして CORS の設定を変更していきます。
src/server.ts
import { hash } from 'node:crypto'
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { getCookie, setCookie } from 'hono/cookie'
const html = `
<script>
fetch('/api/secret')
.then(res => res.text())
.then(text => console.log(text))
</script>
`
const app = new Hono()
app.use('/api/*', cors(
{
origin: 'http://localhost:3001'
}
))
app.get('/api/secret', (c) => {
if (!getCookie(c, 'session')) return c.json({ message: 'Unauthorized' }, 401)
return c.text('絶対に秘匿したい情報です。')
})
app.get('/', (c) => {
const session = getCookie(c, 'session')
if (!session) setCookie(c, 'session', hash('sha256', new Date().toString()), { maxAge: 3600 })
return c.html(html)
})
const port = 3000
console.log(`Server(server) is running on port ${port}`)
serve({
fetch: app.fetch,
port
})
src/browser.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const html = `
<script>
fetch('http://localhost:3000/api/secret')
.then(res => res.text())
.then(text => console.log(text))
</script>
`
const app = new Hono()
app.get('/', (c) => {
return c.html(html)
})
const port = 3001
console.log(`Server(browser) is running on port ${port}`)
serve({
fetch: app.fetch,
port
})
簡単に上記のコードを説明すると、
-
src/server.ts
- CORS リクエスト対象のウェブサイトです。
localhost:3000
で提供されます - 利用には認証が必要です
- 今回は簡単のために初回アクセス時に 3600 秒間有効な認証トークンを Cookie (key:session) に設定します[2]
- また、絶対に秘匿したい情報を返却する API (
/api/secret
) を提供します(Cookie による認証を要する)
- CORS リクエスト対象のウェブサイトです。
-
src/browser.ts
- CORS リクエストを実行するウェブサイトです。リクエスト対象とは異なるオリジン(
localhost:3001
)で提供されます -
http://localhost:3000/api/secret
へのリクエストを成功させることを目的とします
- CORS リクエストを実行するウェブサイトです。リクエスト対象とは異なるオリジン(
Cross-Origin ではない場合
localhost:3000
を開くと、コンソールに以下が出力されます。
当然ですが同一オリジンなので、問題なくリクエストが成功しています。
またこの時のネットワークリクエストは以下です。
Cookie に認証トークンが設定された状態でリクエストが行われていることが分かります。
CORS を許可しない場合
CORS を許可しない場合(上記のコードそのまま)です。
この状態で localhost:3001
を開くと、コンソールには以下のようなエラーが出力されます。
雑に要約すれば http://localhost:3001
から、http://localhost:3000/api/secret
へのリクエストを CORS policy によりブロックした。許可したい場合は Access-Control-Allow-Origin
ヘッダーをレスポンスに指定してくれ。と言っています。
今回は CORS を許可しない設定をしているので、適切に処理されていることが分かります。
CORS リクエストを許可する場合
次に http://localhost:3001
からの CORS リクエストを許可する場合です。
Access-Control-Allow-Origin
ヘッダーに http://localhost:3001
を設定します。
diff
2a3
> import { cors } from 'hono/cors'
5a7,11
> app.use('/*', cors(
> {
> origin: '*'
> }
> ))
この状態で localhost:3001
を開くと、コンソールには以下のようなエラーが出力されます。
localhost:3001 からの CORS リクエストが許可されている他、CORS のエラーは出なくなりました。しかし認証トークンがリクエストに含まれていないため 401
が返却されます。
またこの時のネットワークリクエストは以下です。
リクエストに認証トークンが含まれていないことが確認できます。
また、レスポンスヘッダーに http://localhost:3000
を指定して Access-Control-Allow-Origin
が設定されていることも確認できます。
CORS リクエストを許可する場合(リクエストヘッダーに認証トークンを含める)
次に、1つ前の状態から、リクエストヘッダーに認証トークンを含めた場合です。
Request.credentials
(ref) を include
に設定します。
diff
6c6
< fetch('http://localhost:3000/api/secret')
---
> fetch('http://localhost:3000/api/secret', { credentials: 'include' })
この状態で localhost:3001 を開くと、コンソールには以下のようなエラーが出力されます。
雑に要約すれば http://localhost:3001
から、http://localhost:3000/api/secret
へのリクエストを CORS policy によりブロックした。許可したい場合は Access-Control-Allow-Credentials
ヘッダーをレスポンスに指定してくれ。と言っています。
CORS を許可しない場合と似たエラーですが、今回設定を要求されているのは Access-Control-Allow-Origin
ではなく Access-Control-Allow-Credentials
ヘッダーです。
またこの時のネットワークリクエストは以下です。
Cross-Origin なリクエストでも認証トークンを含めてリクエストが行われていることが分かります。
また、CORS を許可しない場合と異なり、ステータスコードが 200
で返却されています。つまり、リクエスト自体は認証されたうえで成功していますが、CORS policy に従いブラウザによりブロックされているということです。
CORS リクエストを許可する場合(認証情報を含むリクエストを許可する)
最後に1つ前の状態から、認証情報を含むリクエストを許可された場合です。
Access-Control-Allow-Credentials
ヘッダーに true
を設定します。
diff
@@ -18,4 +18,5 @@
{
origin: 'http://localhost:3001',
+ credentials: true
}
))
この状態で localhost:3001 を開くと、コンソールには以下のように、/api/secret
へのリクエストの返却値が出力されます。
これで無事に? Cross-Origin なリクエストでも Cookie により認証されたリクエストを送信できました。
またこの時のネットワークリクエストは以下です。
レスポンスヘッダーに true
を指定して Access-Control-Allow-Credentials
が設定されていることも確認できます。
まとめ
-
Access-Control-Allow-Origin
ヘッダーで CORS リクエストを許可するオリジンを指定できる -
Access-Control-Allow-Credentials
ヘッダーで認証情報を含むリクエストを許可するかどうかを指定できる - 上記の設定が不適切な場合、許可したくないオリジンからのリクエストを許可してしまうことがある
- 今回は
http://localhost:3001
という単一のオリジンのみを許可する設定をしていますが、実運用としては全てのサブドメインを許可したり、ワイルドカードを使用して特定のパターンのオリジンを許可する設定をすることが多いと思います。その際にはワイルドカードが許可したくないオリジンまで含まないことを確認する必要があります。
- 今回は
余談
CORS リクエストを許可する場合リクエストヘッダーに認証トークンを含める では、リクエスト自体は成功しているがブラウザによりブロックされていると記載しましたが、これは以下のようにネットワークリクエストを直接取得すれば確認できます。(下記は Burp Suite の Proxy でのキャプチャーです)
CORS はブラウザにおける設定ですので、レスポンス自体は正常に行われているということに留意する必要があります。
本当に余談
pnpm では以下のようにすることで並列で script を実行できることを初めて知りました。便利ですね。
"scripts": {
"dev": "pnpm run /^dev:/",
"dev:server": "tsx watch src/server.ts",
"dev:browser": "tsx watch src/browser.ts"
},
Discussion