💨

認証情報付きの CORS(Cross-Origin Resource Sharing)リクエストを成功させる

2024/09/21に公開

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
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
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 による認証を要する)
  • src/browser.ts
    • CORS リクエストを実行するウェブサイトです。リクエスト対象とは異なるオリジン(localhost:3001)で提供されます
    • http://localhost:3000/api/secret へのリクエストを成功させることを目的とします

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
src/server.ts
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
src/browser.ts
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
src/server.ts
@@ -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"
  },

参考

脚注
  1. CORS の設定で全てのリクエストを許可する場合も同じです ↩︎

  2. httpOnly 等は今回の実験には不要なため設定していません。実際に Cookie を利用する際には他のプロパティも適切に設定する必要があります。 ↩︎

Discussion