TL;DR
- Vercel から Cloudflare Workers (
@opennextjs/cloudflare) に移行したら、 R2 の Signed URL を Worker から fetch する経路だけ 400 Bad Request になった - 同じ URL を curl で叩くと 200 が取れる、 Worker fetch だけ 400
- 仮説:
@aws-sdk/client-s3v3.552+ が GetObject の Signed URL にx-amz-checksum-mode=ENABLEDを常時注入、 R2 側の同 header の挙動が揃っておらず、 Workers fetch と組み合わさって 400 になる、 と読むのが公開報告と整合する - 対処は (A) signed URL から checksum-mode を外す (未検証) か (B) Worker fetch を経由しない経路に切り替える。 我々は B を採用して復旧
背景
社内の Next.js アプリ (app.example.com とします) を Vercel → Cloudflare Workers に @opennextjs/cloudflare で移行した直後、 見積書プレビューの印鑑画像 (R2 上、 Signed URL 経由) が表示されなくなりました。
症状
印鑑画像は @react-pdf/renderer の Image が /api/proxy/image (Next.js API route) 経由で R2 を叩く構造で、 CORS と SSRF 防御をまとめていました。
<Image src={`/api/proxy/image?url=${encodeURIComponent(companyProfile.seal)}`} />
この /api/proxy/image が Worker 上で常に 500 を返します。 Vercel 時代は同じコードで動いていました。
一次調査: redirect: 'error' が Workers で未実装 (false flag)
wrangler tail で次の例外が拾えました。
TypeError: Invalid redirect value, must be one of "follow" or "manual"
("error" won't be implemented since it does not make sense at the edge;
use "manual" and check the response status code).
Workers fetch ランタイム は redirect: 'error' を未実装で、 Node.js 互換の fetch を期待して書いたコードがそのままでは動かない、 という形でした。
// 旧: TypeError
const response = await fetch(url, { redirect: 'error' })
// 修正: 'manual' + 3xx 拒否で SSRF 防御の意図を維持
const response = await fetch(url, { redirect: 'manual' })
if (response.status >= 300 && response.status < 400) {
return new NextResponse('URL not allowed', { status: 400 })
}
TypeError 自体は解消しましたが、 印鑑画像は引き続き出ません。 本来の原因は別でした。
真因の作業仮説: AWS SDK の checksum-mode 注入
wrangler tail を再取得すると別メッセージが見えます。
Proxy error: Error: Failed to fetch image: Bad Request
R2 が 400 を返しています。 が、 同じ Signed URL を curl で外から叩くと 200 が取れます。
$ curl -sS -I 'https://<bucket>.<account>.r2.cloudflarestorage.com/...?X-Amz-Signature=...'
HTTP/2 200
$ curl -sS -I 'https://app.example.com/api/proxy/image?url=<同じ URL>'
HTTP/2 500
Signed URL の query を見ると x-amz-checksum-mode=ENABLED が混入していました。 これは @aws-sdk/client-s3 v3.552+ が GetObject に ChecksumMode: ENABLED をデフォルト付与する挙動 (Issue #6994) で、 R2 側ではこの header の扱いに揺れがある旨が Cloudflare Community でも複数報告されています。
つまり当初疑った 「Cloudflare の内部経路最適化」 ではなく、 SDK が注入する checksum-mode が引き金で、 R2 と Workers fetch のどこかに不整合が出ている と読むのが整合しそうです。 真因の最終確定は我々の側だけでは取れず、 現時点では公開報告以上の確証は得られていません。
修正の選択肢
案 A: Signed URL から x-amz-checksum-mode を外す (未検証)
SDK の仕様と公開報告から成立すると考えていますが、 案 B で本番症状が消えたため単体検証していません。
// (1) unhoistableHeaders で query から外す
const url = await getSignedUrl(client, new GetObjectCommand({ Bucket, Key }), {
expiresIn,
unhoistableHeaders: new Set(['x-amz-checksum-mode']),
})
// (2) SDK config で checksum 抑制 (v3.729+)
const client = new S3Client({ ..., responseChecksumValidation: 'WHEN_REQUIRED' })
サーバ側のみで完結し、 Worker から直接 R2 を叩く構成を維持できます。
案 B: Worker fetch を経由しない経路に切り替える (採用)
Cloud Run の Hono サーバに proxy を移設しました。 Cloud Run の Node.js fetch は標準ランタイムなので checksum-mode 込みの URL もそのまま 200 で取れます。
// server 側 routes/image-proxy.ts (要点のみ)
export const imageProxyRouter = new Hono<Env>().get('/', async (c) => {
const url = c.req.query('url')
if (!url || !isAllowedProxyUrl(url)) return c.text('URL not allowed', 400)
const response = await fetch(url, { redirect: 'manual' })
if (response.status >= 300 && response.status < 400) return c.text('URL not allowed', 400)
if (!response.ok) throw new Error(`Failed to fetch image: ${response.statusText}`)
return new Response(await response.arrayBuffer(), {
headers: {
'Content-Type': response.headers.get('Content-Type') ?? 'image/png',
'Access-Control-Allow-Origin': '*', // @react-pdf canvas 読み取り用
},
})
})
フロント側は <Image src> を ${apiBaseUrl}/image-proxy?url=... に切替えるだけです。 dev に deploy したら一発で 200 + PNG が返ってきました。
どちらを選ぶか
| 観点 | 案 A (未検証) | 案 B (採用) |
|---|---|---|
| 検証状況 | 未検証 | dev / prod で 200 確認済 |
| 影響範囲 | server の signed URL 生成のみ | proxy 経路の追加と切替 |
| SDK アップグレード耐性 | 弱 (SDK 内部仕様依存) | 強 (経路自体を切替) |
| 責務分離 | Worker に R2 アクセスを残す | UI と API の境界が明確 |
「SDK の挙動がまた変わる可能性」 と 「Workers / Node.js の fetch 差分の不確定さ」 を天秤にかけて B を選びました。 SSRF allowlist 込み 100 行未満で本番症状が消えました。 案 A を試した方のレポートが共有されると、 この記事の価値も上がります。
まとめ
| 罠 | 症状 | 対処 |
|---|---|---|
redirect: 'error' が Workers で未実装 |
fetch が TypeError |
'manual' + status code チェック |
| Worker → R2 の Signed URL fetch が 400 | curl では 200 が取れる |
unhoistableHeaders 等で x-amz-checksum-mode 除去 (未検証)、 もしくは Worker fetch 経由を回避 (採用) |
特に後者は curl で外から動作確認しても気付けないため、 移行時は Worker 上で Signed URL を fetch する経路を全部洗い出して一度通しておく のが安全です。
「Vercel で動いたから動く」 を疑える視点を、 Cloudflare 移行を進める仲間と共有できれば幸いです。
参考リンク:
- Cloudflare Workers fetch() API
- Cloudflare R2 Presigned URLs
- aws/aws-sdk-js-v3 #6994 "S3 GetObject ChecksumMode always ENABLED"
- aws/aws-sdk-js-v3 #5125 "getSignedUrl with ChecksumMode ENABLED Fails"
- Cloudflare Community: X-amz-checksum-mode not implemented
- Cloudflare Community: @aws-sdk/client-s3 v3.729.0 Breaks R2 S3 API Compatibility
- @opennextjs/cloudflare