ブログ一覧

Webhook 署名検証が body パースで壊れる問題と、生リクエストボディの取得(Astro / Cloudflare Workers)

Slack や Stripe、GitHub などの Webhook は、リクエストが本物かを確認するために署名検証(HMAC)を行います。この検証は「送られてきた生のリクエストボディ(バイト列)」に対して計算されます。ところが、フレームワークや CMS がリクエストボディを先にパースしてしまうと生ボディが取れなくなり、署名検証が通らなくなります。ハマりやすい罠なので、原因と Astro / Cloudflare Workers での対処をまとめます。

前提バージョン: 本記事のコードは Astro 7 系 / Cloudflare Workers(module worker 構文)を想定しています。Astro の例は on-demand rendering(SSR。output: "server" などサーバーレンダリング構成のルート)が前提です。使用する API はいずれも Web 標準の `Request` なので、Astro 3 以降でおおむね共通です。

なぜ「生ボディ」でないとダメなのか

署名は送信側が送ったそのままのバイト列に対して HMAC-SHA256 を計算します。受信側でボディを一度 JSON にパースし、それを JSON.stringify で再シリアライズしても、キーの順序や空白が変わってバイト列が一致せず、署名が合いません。だから「パース済みのオブジェクト」ではなく「生のボディ文字列」が必要です。

SlackStripe も、公式ドキュメントで「body をパースするミドルウェアが署名検証を壊す」と明記しています。

症状

典型は Express の express.json()。これを通すと req.bodyパース済みで、生ボディが手元に残りません。同じことが Astro や Next.js、CMS の body パースでも起きます。「署名検証が常に失敗する」ときは、まずどこかで body がパースされていないかを疑ってください。

Astro(API エンドポイント)での取得

Astro の エンドポイント は、標準の Web Request をそのまま渡します。src/pages/api/... のルートで request.text() を呼べば生ボディが読めます。

// src/pages/api/slack/events.ts
import type { APIRoute } from "astro";

export const POST: APIRoute = async ({ request }) => {
  const rawBody = await request.text();          // 生ボディ(署名検証用)
  const sig = request.headers.get("x-slack-signature");
  const ts = request.headers.get("x-slack-request-timestamp");

  if (!verifySlackSignature(rawBody, ts, sig, signingSecret)) {
    return new Response("invalid signature", { status: 401 });
  }

  const payload = JSON.parse(rawBody);           // 検証してから自分でパース
  // ...
  return new Response("ok");
};

ポイントは、フレームワークにパースさせず、自分で `request.text()` → 検証 → `JSON.parse` の順にすることです。

Cloudflare Workers での取得

Workers も同じく Web 標準の Request です。fetch ハンドラで await request.text() すれば生ボディが取れます。

export default {
  async fetch(request: Request): Promise<Response> {
    const rawBody = await request.text();
    // 署名検証 → JSON.parse
    return new Response("ok");
  },
};

注意:body ストリームは一度しか読めない

Request の body は一度読むと再度読めません。パースと署名検証の両方で body が必要なら、次のどちらかにします。

  • 生ボディを request.text() で読み、検証後に自分で `JSON.parse` する(上の例)。
  • どうしても両方の API を使いたいなら、`request.clone()` で複製してから片方を読む。

フレームワーク / CMS が先に body を消費してしまう場合

問題が根深いのは、フレームワークや CMS のルーティングが handler を呼ぶ前に body をパースし、生ボディを露出しないケースです。この場合の選択肢:

  • 署名検証エンドポイントだけ、フレームワークの body パースを迂回する(プレーンな Web ハンドラや別ルートに置く)。
  • フレームワーク側に生ボディを露出する仕組み(ルートオプションやパッチ)を用意する。

いずれにせよ原則は同じで、「署名検証は body がパースされる前の生バイト列で行う」です。

まとめ

  • Webhook 署名検証は生のリクエストボディに対する HMAC。パース→再シリアライズでは一致しない。
  • Astro / Cloudflare Workers は標準 Request なので await request.text() で生ボディを取れる。自分でパースする前に検証する。
  • body は一度しか読めない。両方要るなら request.clone()
  • フレームワーク / CMS が先に body を消費する場合は、そのルートだけパースを迂回するか、生ボディを露出させる。

参考リンク