Cloudflare Pages Functionsのエラー応答の落とし穴(CORS, Set-Cookie, XFF偽装など)を、プログラマーとフロー図やコードで視覚的に解説しています。記事の修正ポイントを示唆するイメージです。

はじめに

Cloudflare Pages Functions でミドルウェアやプロキシ層を書いていると、正常系は動くのにエラー応答まわりだけ静かに壊れている、ということがよくあります。
実運用しているサービスで見直したところ、「地味だが効く」不具合が5つまとまって見つかりました。

エラー応答は「ステータスの意味」「本文に何を出すか」「ブラウザが読めるか(CORS)」の3つを同時に正しくする必要があります。
この記事では、同じ問題に当たったときのチェックリストとして使えるよう、5つの落とし穴と直し方をコード付きで整理します。

こんな人におすすめ

  • Cloudflare Pages Functions や Workers でミドルウェア・プロキシを書いている方
  • 外部サービスへのリクエストを中継していて、Set-Cookie や X-Forwarded-For の扱いに自信がない方
  • ブラウザから 500 エラーが「ネットワークエラー」に見えて原因調査に苦労した方
  • エラー応答の設計をレビューするためのチェック項目が欲しい方

落とし穴1: 例外メッセージが応答本文に漏れる

catch で受けた例外の message をそのまま JSON で返していると、上流サービスの応答本文や内部情報が混ざることがあります。
応答は固定文字列にして、詳細はログにだけ残すのが安全です。

try {
  return await handle(request)
} catch (error) {
  // 詳細はサーバー側のログにだけ残す
  console.error('middleware error', error)
  // 利用者に返すのは固定文字列
  return new Response(JSON.stringify({ error: 'Internal error' }), {
    status: 500,
    headers: { 'Content-Type': 'application/json' },
  })
}

プロキシ層なら Proxy error のように、どの層で起きたかが分かる程度の固定文字列にしておくと、問い合わせ対応もしやすくなります。

落とし穴2: TypeError が 400 で返っている

例外の種類によって、ステータスを分けて考えます。

例外 主な原因 ステータス
SyntaxError 要求本文の JSON パース失敗 400
TypeError / RangeError ハンドラー側のバグであることが多い 500

利用者の誤りではなくこちらのバグなのに 400 を返すと、監視でも「クライアントのミス」として埋もれてしまいます。

function statusFromError(error: unknown): number {
  if (error instanceof SyntaxError) return 400
  // TypeError / RangeError などはハンドラー側の不具合として扱う
  return 500
}

落とし穴3: CORS ヘッダーの補完が実は効いていない

「ヘッダーが付いていなければ付ける」という分岐を書いたつもりでも、組み立てたヘッダーを作ったあとに別の Response を返していて、補完結果を捨てていたケースがありました。
条件分岐のテストがなかったため、長く気づけませんでした。

直したあとは「足りないものだけ足す」形にし、Vary: Origin も補っています。

createCORSHeaders(origin).forEach((value, key) => {
  if (!newHeaders.has(key)) {
    newHeaders.set(key, value)
  } else if (key === 'vary' && !/(^|,)\s*origin\s*(,|$)/i.test(newHeaders.get(key) ?? '')) {
    newHeaders.append('Vary', value) // キャッシュの鍵に Origin を足す
  }
})

ハンドラーが付けた値は上書きせず、Vary だけは既存値に Origin を追記します。
オリジンごとに応答が変わるのにキャッシュの鍵に Origin がないと、別オリジン向けの応答が配られる恐れがあるためです。

例外時・最後の 500 にも CORS ヘッダーを付ける

意外と見落とされるのがエラー応答です。
CORS ヘッダーのない 500 は、ブラウザからは中身が読めず、単なる「ネットワークエラー」に見えます。
検証済みのオリジンを try の外の変数に持っておくと、catch 側でも使えます。

let allowedOrigin: string | undefined // try の外で保持

try {
  allowedOrigin = validateOrigin(request)
  // ...
} catch (error) {
  const errorResponse = new Response('Internal error', { status: 500 })
  return allowedOrigin ? addCORSHeaders(errorResponse, allowedOrigin) : errorResponse
}

プロキシで上流の Set-Cookie を転送するとき、headers.get('Set-Cookie') を使うと、複数の Cookie が , でつながれた1本の文字列になります。
Expires=Wed, 21 Oct ... にもカンマがあるため、後から正しく分割することはできません。

そこで1つずつ読み出す関数を用意し、実行環境ごとに使える API を順に試します。

function readSetCookies(headers: Headers): string[] {
  const h = headers as Headers & {
    getSetCookie?: () => string[]
    getAll?: (name: string) => string[]
  }
  if (typeof h.getSetCookie === 'function') {
    const cookies = h.getSetCookie()
    if (cookies.length > 0) return cookies
  }
  if (typeof h.getAll === 'function') {
    try {
      const cookies = h.getAll('Set-Cookie')
      if (cookies && cookies.length > 0) return cookies
    } catch {
      // 下のフォールバックへ
    }
  }
  const single = headers.get('Set-Cookie')
  return single ? [single] : []
}

標準の getSetCookie()、Workers 独自の getAll()、単一値の get の順に試す構成です。
転送側では、いったん delete('Set-Cookie') してから、読み出した Cookie を1つずつ append します。

const outHeaders = new Headers(upstream.headers)
outHeaders.delete('Set-Cookie')
for (const cookie of readSetCookies(upstream.headers)) {
  outHeaders.append('Set-Cookie', cookie)
}

落とし穴5: X-Forwarded-For を偽装できる

クライアントが送ってきた X-Forwarded-For や X-Real-IP をそのまま引き継いで上流へ渡すと、IP を偽装できてしまいます。
上流側で IP 制限やレート制限をしている場合、素通りされる原因になります。

Cloudflare が付与する CF-Connecting-IP だけを信頼し、転送用のヘッダーはこちらで作り直します。

const clientIp = request.headers.get('CF-Connecting-IP')
upstreamHeaders.delete('X-Forwarded-For')
upstreamHeaders.delete('X-Real-IP')
if (clientIp) {
  upstreamHeaders.set('X-Forwarded-For', clientIp)
}

つまづきやすいポイント

  • 挙動が変わる点は明記する: 400 から 500 への変更、CORS ヘッダーの新規付与、User-Agent や X-Forwarded-For の変更は、許可リストや IP 制限を持つ連携先に影響しえます。確認できていない項目も「未確認」として PR に書いておくと、レビューしやすくなります
  • ランタイム差分: getSetCookie や getAll が使えるかは実行環境によって異なります。フォールバックの順序は、実機で確認することをおすすめします
  • 分岐のテスト不足: 今回の CORS の不具合は、条件分岐を通すテストがなかったことが原因でした。CORS 補完や例外分類を網羅するフローテストを足しておくと、同種の見落としを減らせます
  • ログの出し忘れ: 応答を固定文字列にすると、ログがなければ原因を追えません。詳細を残す処理とセットで入れてください

まとめ

エラー応答は、正常系より後回しになりやすい一方で、セキュリティと運用のしやすさに直結します。
例外はステータスと本文を分けて考え、本文には固定文字列を返し、CORS ヘッダーはエラー時にも付け、Set-Cookie は1つずつ扱い、IP は信頼できるヘッダーだけで作る、という5点がチェックリストになります。
自分のミドルウェアでも、一度エラー経路だけを通して確認してみてください。