LambdaのPythonでGitHubのWebhookから送られてくるHAMC値をチェックする

LambdaのPythonGitHubのWebhookから送られてくるHAMC値をチェックするコード

# -*- coding: utf-8 -*-
from __future__ import print_function
import json, hmac, hashlib, base64, urllib

def checkHmac(event) -> bool:
    """GitHubのWebhookから送られてくるHAMC値をチェックする"""
    # 送信者と受信者で秘密情報であるSecretの値
    secret = 'GitHubのWebhookで設定したSecretの値(直書きしないで環境変数とかSecrets Managerから取得すべし)'
    # GitHubから送られてきた情報にあったHMAC値
    sent_hmac = event['headers']['x-hub-signature-256']
    print('SHA-256ハッシュ関数用のHMAC値:' + sent_hmac)

    # Secretの値をbytes型に変換する
    secret_bytes = bytes(secret, 'utf-8')
    if (event['isBase64Encoded']):
        # bodyをbase64でデコードしてbytes型にする
        body_bytes = base64.b64decode(event['body'])
    else:
        # bodyをbytes型に変換する
        body_bytes = bytes(event['body'], 'utf-8')

    # 「Secret + メッセージ + ハッシュ関数」でHMAC値を作る
    created_hmac = 'sha256=' + hmac.new(secret_bytes, body_bytes, hashlib.sha256).hexdigest()
    print('自分で作ったHMAC値:' + created_hmac)

    # 2つのHMAC値が同じであったら改竄されていないと判断する
    return hmac.compare_digest(created_hmac, sent_hmac)

def lambda_handler(event, context):
    if checkHmac(event):
        print('HMAC値で認証できた!')
        # base64デコードした上で文字列にする
        b64decd = base64.b64decode(event['body']).decode()
        # URLデコードして「payload=」を削除する
        urldecd = urllib.parse.unquote(b64decd).lstrip('payload=')
        # 辞書型にする
        body = json.loads(urldecd)
        # そして処理で使う
        print(body)
    else:
        print('へんなHMAC値がGitHubから送られてきた!')

f:id:ponsuke_tarou:20210407234526j:plain
東京都あきる野市今熊山のすみれ

ことの発端は試験勉強

  1. じみに続けている情報処理試験の勉強でメッセージ認証を勉強していた。
  2. そういえば以前Backlogの課題にGitHubのコミットを連携するをやった時にGitHubのWebhookでHMAC値を使ったのを思い出して、今一度やってみようと思った。
  3. 手頃にGitHubでWebhookを設定
  4. AWSAPI GatewayとLambdaをノリで作ってみた。
  5. WebhookからきたHMAC値と自作したHMAC値が一致しない!

f:id:ponsuke_tarou:20210407234800j:plain
金剛の滝

GitHubのWebhookが送ってくるbodyがへんだ

print(event)してCloudWatch Logsをみたら・・・bodyが・・・肉眼で読めない・・・。

{
   "version": "2.0",
...
  "body": "cGF5bG9hZD0lN0IlMjJ...=",
  "isBase64Encoded": true
}

アルファベットと数字と最後にある=からBase64エンコードされてるっぽい・・・前にやった時は普通に読めたのに・・・・。

ペイロードがBase 64でエンコードされている

じっと見つめると "isBase64Encoded": true と最後に書いてある・・・なにこれ?

Lambda プロキシ統合の場合、API Gateway は、次のようにクライアントリクエスト全体をバックエンド Lambda 関数の入力 event パラメータにマッピングします。

...省略...

"isBase64Encoded": "A boolean flag to indicate if the applicable request payload is Base64-encoded"

API Gateway で Lambda プロキシ統合を設定する - Amazon API Gateway

訳すと「適用可能なリクエスト・ペイロードがBase 64でエンコードされているかどうかを示す真偽フラグ」。

えっ?Base64エンコードやっぱされている

f:id:ponsuke_tarou:20210407234849j:plain
今熊神社の三つ葉つつじ

HMAC値を作るにはbodyをBase64デコードします。

# 「cGF5bG9hZD0lN0IlMjJ...=」なbodyをBase64デコードしてbytes型にしてあげる
body_bytes = base64.b64decode(event['body'])
# bytes型にしたbodyを使ってHMAC値を作ろう
created_hmac = 'sha256=' + hmac.new(secret_bytes, b64decd, hashlib.sha256).hexdigest()

WebhookからきたHMAC値は X-Hub-SignatureではなくX-Hub-Signature-256 を使うのがお勧めです。

{
    ...省略...
    "headers": {
        ...省略...
        "x-hub-signature": "sha1=sha1のHMAC値",
        "x-hub-signature-256": "sha256=sha256のHMAC値"
    },
    ...省略...
}
ヘッダ 説明
X-Hub-Signature このヘッダは、webhook が secret で設定されている場合に送信されます。 これはリクエスト本文の HMAC hex digest であり、SHA-1 ハッシュ関数と HMAC keyとしての secret を使用して生成されます。 X-Hub-Signature は、既存の統合との互換性のために提供されているため、より安全な X-Hub-Signature-256 を代わりに使用することをお勧めします。
X-Hub-Signature-256 ​このヘッダは、webhook が secret で設定されている場合に送信されます。 これはリクエスト本文の HMAC hex digest であり、SHA-256 ハッシュ関数と HMAC key としての secret を使用して生成されます。

表の出典 : webhook イベントとペイロード - GitHub Docs

isBase64EncodedがFalseの時も考えておきます。

isBase64EncodedFalseだとBase64エンコードはされなさそうなので処理の分岐を作っておきます。

    if (event['isBase64Encoded']):
        # bodyをbase64でデコードしてbytes型にする
        body_bytes = base64.b64decode(event['body'])
    else:
        # bodyをbytes型に変換する
        body_bytes = bytes(event['body'], 'utf-8')

処理でBodyの内容を使う時はさらに加工して使うといいでしょう。

        # base64デコードした上で文字列にする
        b64decd = base64.b64decode(event['body']).decode()
        # こんな感じになる >>> payload=%7B%22ref%22%3A%22refs%2Fheads%2Fmaster%22%2C%22before...
        print(b64decd)
        # URLデコードして「payload=」を削除する
        urldecd = urllib.parse.unquote(b64decd).lstrip('payload=')
        # こんな感じになる >>> {'ref': 'refs/heads/master', 'before...
        print(urldecd)
        # 辞書型にすると使いやすい(と思う)
        body = json.loads(urldecd)

f:id:ponsuke_tarou:20210407234635j:plain
今熊山の名前のわからない花