1
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?

Fastify で GitHub Webhook を安全に受ける

Posted at

image.png

GitHub Webhook を受信する際に必要な署名検証(HMAC)冪等(べきとう)性の担保即レスポンス設計を、Fastifyで実装します。

本当は Stripe 連携を想定した内容を予定してましたが、CLI のインストールが必要だったりと準備が必要だったので、GitHub Webhook を使ってます...。

やっていること

  • GitHub Webhook の X-Hub-Signature-256 を使った署名検証の実装
  • X-GitHub-Delivery を使った重複配信の防止(冪等性)
  • Webhook ハンドラを軽く保つための「即ACK(Acknowledgement) → バックグラウンド処理」の設計
  • ローカル環境で GitHub を使わずに検証する方法

技術スタック

  • Node.js: v20+
  • Fastify: v5.6.2
  • TypeScript: v5.7.2

Webhookの3つの課題

Webhook は外部サービスがPOSTしてくるイベント通知なので、考慮すべき課題があります。

1. 改ざんのリスク

公開URLでWebhookを受ける場合、第三者が偽のリクエストを送る可能性がある。
署名検証

2. 重複配信

ネットワーク障害やタイムアウトにより、同じイベントが複数回届くことがある。
冪等性の担保

3. タイムアウト

Webhookの送り手(GitHub)は、一定時間内にレスポンスが返らないと「失敗」と判断して再送する。
即座にACKを返し、重い処理は分離する

プロジェクトのセットアップ

依存関係のインストール

こちらにまとめます
npm install fastify fastify-raw-body dotenv
npm install -D typescript tsx @types/node

TypeScript設定

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "moduleDetection": "force",
    "allowImportingTsExtensions": false,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true
  },
  "include": ["src"]
}

package.json

{
  "name": "fastify-github-webhook",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx src/server.ts",
    "send:test": "tsx src/send-test.ts"
  },
  "dependencies": {
    "dotenv": "^17.2.3",
    "fastify": "^5.6.2",
    "fastify-raw-body": "^5.0.0"
  },
  "devDependencies": {
    "@types/node": "^22.10.7",
    "tsx": "^4.19.2",
    "typescript": "^5.7.2"
  }
}

実装:サーバー側

全体コード
src/server.ts
import Fastify from "fastify";
import rawBody from "fastify-raw-body";
import crypto from "node:crypto";
import "dotenv/config";

const app = Fastify({ logger: true });

await app.register(rawBody, {
  field: "rawBody", // request.rawBody に入る
  global: false, // 必要なルートだけ有効化
  encoding: "utf8",
  runFirst: true,
});

type DeliveryStore = {
  has: (id: string) => Promise<boolean>;
  add: (id: string) => Promise<void>;
};

// デモ用:メモリ保存(本番はDB/Redis推奨)
const deliveries = new Set<string>();
const store: DeliveryStore = {
  async has(id) {
    return deliveries.has(id);
  },
  async add(id) {
    deliveries.add(id);
  },
};

function timingSafeEqualHex(a: string, b: string) {
  const aBuf = Buffer.from(a, "utf8");
  const bBuf = Buffer.from(b, "utf8");
  if (aBuf.length !== bBuf.length) return false;
  return crypto.timingSafeEqual(aBuf, bBuf);
}

function verifyGitHubSignature(
  rawBody: string,
  secret: string,
  signature256?: string,
) {
  if (!signature256) return false;

  // GitHubの X-Hub-Signature-256 は "sha256=<hex>" 形式
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");

  return timingSafeEqualHex(expected, signature256);
}

app.post(
  "/webhook/github",
  {
    config: { rawBody: true }, // このルートだけ rawBody を有効化
  },
  async (request, reply) => {
    const secret = process.env.GITHUB_WEBHOOK_SECRET ?? "dev_secret_123";
    const sig256 = request.headers["x-hub-signature-256"] as
      | string
      | undefined;
    const deliveryId = request.headers["x-github-delivery"] as
      | string
      | undefined;
    const eventName = request.headers["x-github-event"] as string | undefined;

    const raw = (request as any).rawBody as string | undefined;

    // 1) 署名検証(まず最初にやる)
    if (!raw || !verifyGitHubSignature(raw, secret, sig256)) {
      request.log.warn(
        { deliveryId, eventName },
        "signature verification failed",
      );
      return reply.code(401).send({ ok: false });
    }

    // 2) 冪等性(Delivery IDで重複排除)
    // GitHubの配信にはX-GitHub-Deliveryが付く
    if (deliveryId && (await store.has(deliveryId))) {
      request.log.info(
        { deliveryId, eventName },
        "duplicate delivery ignored",
      );
      return reply.code(200).send({ ok: true, duplicate: true });
    }
    if (deliveryId) await store.add(deliveryId);

    // 3) 受信は即ACK(Webhookの鉄則)
    reply.code(200).send({ ok: true });

    // 4) 重い処理は分離(ここから先はバックグラウンド)
    void handleEvent(eventName ?? "unknown", JSON.parse(raw)).catch((e) => {
      request.log.error(
        { err: e, deliveryId, eventName },
        "event handling failed",
      );
    });
  },
);

async function handleEvent(eventName: string, payload: any) {
  // 例:push / pull_request / issues で分岐
  switch (eventName) {
    case "push":
      // payload.ref / commits など
      break;
    case "pull_request":
      // payload.action / pull_request.title など
      break;
    default:
      break;
  }
}

await app.listen({ port: 4100, host: "0.0.0.0" });

ポイント

1. raw bodyの取得

署名検証にはパース前の生データが必要です。fastify-raw-body プラグインで request.rawBody を取得します。

await app.register(rawBody, {
  field: "rawBody",
  global: false, 
  encoding: "utf8",
  runFirst: true,
});

ルート単位で有効化するには config: { rawBody: true } を指定します。

2. 署名検証(HMAC-SHA256)

GitHubは X-Hub-Signature-256 ヘッダに sha256=<hex> 形式で署名を送ります。

function verifyGitHubSignature(
  rawBody: string,
  secret: string,
  signature256?: string,
) {
  if (!signature256) return false;

  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");

  return timingSafeEqualHex(expected, signature256);
}

タイミング攻撃対策として crypto.timingSafeEqual を使っています。

3. 冪等性の担保

X-GitHub-Delivery は各配信に付与されるユニークIDです。これをキーに重複を検出します。

if (deliveryId && (await store.has(deliveryId))) {
  return reply.code(200).send({ ok: true, duplicate: true });
}
if (deliveryId) await store.add(deliveryId);

本番環境では Set ではなく、DBやRedisなど永続ストアを使いましょう。

4. 即ACK & 処理分離

検証と重複チェックが通ったら、すぐに200を返します。重い処理はバックグラウンドで実行します。

reply.code(200).send({ ok: true });

// ここから先はバックグラウンド
void handleEvent(eventName ?? "unknown", JSON.parse(raw)).catch((e) => {
  request.log.error({ err: e, deliveryId, eventName }, "event handling failed");
});

ローカル検証用スクリプト

GitHub に実際に Webhook を設定しなくても、ローカルで署名付きリクエストを再現できます。

実際にやる場合はレポジトリをつくって、Webhook の追加を行ってください。

src/send-test.ts
import crypto from "node:crypto";
import "dotenv/config";

const secret = process.env.GITHUB_WEBHOOK_SECRET ?? "dev_secret_123";

const payload = {
  action: "opened",
  repository: { full_name: "yutowac/demo" },
  pull_request: { title: "Test PR" },
};

const raw = JSON.stringify(payload);
const sig256 =
  "sha256=" + crypto.createHmac("sha256", secret).update(raw, "utf8").digest("hex");

const deliveryId = crypto.randomUUID();

const res = await fetch("http://localhost:4100/webhook/github", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-hub-signature-256": sig256,
    "x-github-delivery": deliveryId,
    "x-github-event": "pull_request",
    "user-agent": "GitHub-Hookshot/test",
  },
  body: raw,
});

console.log(res.status, await res.text());

実行方法

  1. サーバー起動:
npm run dev
  1. 別ターミナルでテスト送信:
npm run send:test

>> 200 {"ok":true} #成功時

冪等性のテスト

deliveryId はソースコードではランダムに生成していますが、固定値にして2回送ると検出します。

const deliveryId = "test-delivery-id-123"; 
npm run send:test

>> 200 {"ok":true,"duplicate":true}

補足

本番環境で変えたほうが良いことを補足します。

1. Delivery IDの永続化

メモリ上の Set ではなく、RDBやRedisに保存して、プロセス再起動後も重複検出できるようにします。

// 例: Redisを使う場合
import { createClient } from "redis";

const redis = createClient();
await redis.connect();

const store: DeliveryStore = {
  async has(id) {
    return (await redis.get(`delivery:${id}`)) !== null;
  },
  async add(id) {
    await redis.set(`delivery:${id}`, "1", { EX: 86400 }); // 24時間保持
  },
};

2. ジョブキューの導入

handleEvent() 内で直接重い処理をせず、ジョブとして積みます。

import { Queue } from "bullmq";

const queue = new Queue("webhook-events");

void handleEvent(eventName ?? "unknown", JSON.parse(raw)).catch((e) => {
});

// 実際の処理はジョブキューで
await queue.add("process-event", {
  eventName,
  payload: JSON.parse(raw),
  deliveryId,
});

3. スキーマ検証

eventName ごとに payload を検証します。

import { z } from "zod";

const pullRequestSchema = z.object({
  action: z.string(),
  pull_request: z.object({
    title: z.string(),
    number: z.number(),
  }),
});

if (eventName === "pull_request") {
  const validated = pullRequestSchema.parse(payload);
}

参考

1
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
1
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?