AWS LambdaでGitHub Webhooksのシークレットトークンを検証する

GitHub Webhooksはさまざまなイベントを検知してペイロードを送信できるので、とても便利です。
ただし、セキュリティ対策をしないと意図しないリクエストを処理してしまうかもしれません。

GitHub Webhooksでは、シークレットトークンによる検証の仕組みが用意されており、GitHubからのリクエストに制限することがお勧めされています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368005/b570b055-1f6d-89f2-10e2-539ea152a2f5.png)

今回は、AWS Lamndaで作成したWebhookでシークレットトークンを検証する方法を紹介します。

## シークレットトークンを検証するコード

検証するコードの全体は以下です。
TypeScriptで記載しているため、トランスパイルが必要です。
* ランタイム: Node.js 18.x
* ハンドラ: index.handler
* トリガー: API Gateway (Lambdaプロキシ統合)
* 環境変数： GitHubWebhookSecretにシークレットトークン値を設定

```typescript:index.ts
import {
  APIGatewayProxyEvent,
  APIGatewayProxyEventHeaders,
  APIGatewayProxyResult,
} from 'aws-lambda';
import * as crypto from 'crypto';

/**
 * リクエストのシークレットトークンを検証
 */
export const isValidSignature = (
  body: string | null = '',
  headers: APIGatewayProxyEventHeaders = {}
): boolean => {
  const sigHeaderName = 'X-Hub-Signature-256';
  const sigHashAlg = 'sha256';
  const secret = process.env.GitHubWebhookSecret ?? '';

  if (!headers.hasOwnProperty(sigHeaderName)) {
    return false;
  }

  try {
    const sig = Buffer.from(headers[sigHeaderName] || '', 'utf8');
    const data = body ?? '';
    const hmac = crypto.createHmac(sigHashAlg, secret);
    hmac.update(data);
    const digest = Buffer.from(`${sigHashAlg}=${hmac.digest('hex')}`, 'utf8');

    return crypto.timingSafeEqual(digest, sig);
  } catch (err) {
    console.error(err);
    return false;
  }
};

/**
 * Lambda関数のハンドラ
 */
export const handler = (event: APIGatewayProxyEvent): APIGatewayProxyResult => {
  if (!isValidSignature(event.body, event.headers)) {
    return {
      statusCode: 500,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        message: 'invalid',
      }),
    };
  }

  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      message: 'ok',
    }),
  };
};

```

## 解説
### 検証方法

ペイロードを検証する方法は公式ドキュメントに記載されています。

https://docs.github.com/en/webhooks-and-events/webhooks/securing-your-webhooks#validating-payloads-from-github

要点をまとめると以下です。
* シークレットトークンは `x-hub-signature-256` としてリクエストヘッダーに含まれる
* シークレットトークンの値と、ペイロード本文を使用して、HMAC hexダイジェストのハッシュ値となる
* `==` 演算子ではなく、定数時間の文字列比較をすることが推奨

また、実際に送信されたWebhookのリクエストは、GitHub上から
[Settings]タブ → [Webhooks] → 作成したWebHooksの[Edit] → Recent Deliveries
で、確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/368005/74b68b85-4398-e88d-0c6a-24ebb03df810.png)

### 検証するコード

以上をふまえてコードの解説をします。

* `X-Hub-Signature-256` が存在するか判定します
```typescript
// const sigHeaderName = 'X-Hub-Signature-256';
if (!headers.hasOwnProperty(sigHeaderName)) {
  return false;
}
```

* リクエストヘッダーから `X-Hub-Signature-256`をBufferで取得します
```typescript
// const sigHeaderName = 'X-Hub-Signature-256';
const sig = Buffer.from(headers[sigHeaderName] || '', 'utf8');
```

* シークレットトークンとペイロード本文（リクエストbody）から、sha256でHMACを作成します
```typescript
// const sigHashAlg = 'sha256';
// const secret = process.env.GitHubWebhookSecret;
const data = body ?? '';
const hmac = crypto.createHmac(sigHashAlg, secret);
hmac.update(data);
```

* HMACを16進数形式の文字列として返し、`sha256=` 形式のBufferで取得します
```typescript
// const sigHashAlg = 'sha256';
const digest = Buffer.from(`${sigHashAlg}=${hmac.digest('hex')}`, 'utf8');
```


* それぞれ取得したBufferを定数時間の文字列比較します
```typescript
return crypto.timingSafeEqual(digest, sig);
```

以上で、シークレットトークンの検証ができます。
少し手間ですが、必ずシークレットトークンでの検証をすることをお勧めします。

## 参考にさせていただいたサイト
https://zenn.dev/k_log24/articles/9dcf3c6e0f4a11