
はじめに
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
}
落とし穴4: 複数の Set-Cookie が壊れる
プロキシで上流の 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点がチェックリストになります。
自分のミドルウェアでも、一度エラー経路だけを通して確認してみてください。

