2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Cloudflare Workers から R2 の Signed URL を fetch すると 400 が返る話

2
Posted at

TL;DR

  • Vercel から Cloudflare Workers (@opennextjs/cloudflare) に移行したら、 R2 の Signed URL を Worker から fetch する経路だけ 400 Bad Request になった
  • 同じ URL を curl で叩くと 200 が取れる、 Worker fetch だけ 400
  • 仮説: @aws-sdk/client-s3 v3.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/rendererImage/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 移行を進める仲間と共有できれば幸いです。


参考リンク:

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?