TT Lab
はじめる
学ぶ 学習パス コース

統合とデプロイ

こちらが呼ぶのではなく、こちらが受け取るもの

TT Labで続きを見る

一言でいうと

Webhookは主導権が相手側にある統合であり、受け取る側は、誰が送ったのか(署名)、いつ送ったのか(時刻ウィンドウ)、すでに見たものか(重複)、より新しいものか(順序)の4つを自分で判断しなければなりません。しかも、その判断をすべて終える前に、200を返してはいけません。

なぜ必要なのか

こちらがAPIを呼ぶ統合では、開始時刻も再試行の方針も、こちらが決めます。Webhookは逆です。相手がこちらのアドレスにPOSTし、こちらはそのリクエストを断ることも、先送りすることもできません。ここで、3つの問題が同時にこちらに噛みつきます。

1つ目は、誰でもこちらのアドレスにPOSTできることです。Webhookの受信エンドポイントは、相手が呼べるようにインターネットに開かれていなければなりません。つまり、第三者も呼べるということです。アドレスを誰も知らないということは、防御になりません。

2つ目は、相手が再送してくることです。こちらが200を返すのが遅れたり、返せなかったりすると、相手は失敗と見なして再送します。つまり、こちらの処理時間が長いほど、重複が増えます。しかも、再送はこちらの処理が失敗したときにだけ来るのではありません。こちらはすでに処理したのに、応答が遅れて相手に届かなかった場合のほうが、はるかに多いのです。

3つ目は、順序が保証されないことです。1つの注文について、accepted、paid、shippedの3つのイベントが起きたとしても、その順序で届くとは限りません。相手が並列で送ったり、1件が再試行を繰り返している間に次の1件が先に届いたりすると、順序が入れ替わります。届いた順に上書きすると、注文の状態が後戻りします。

どう動くのか

署名。広く使われている方式は、共有シークレットで計算したHMACをヘッダーに載せて送ることです。RFC 2104がHMACを定義し、実務の実装は、たいていStripeのWebhookドキュメントとGitHubの配信検証ドキュメントが示す形に従います。ヘッダーにタイムスタンプtと署名v1を入れ、署名の材料は、タイムスタンプと元の本文をつなげた文字列です。

ここで最もよくあるミスは、本文をパースしてから再度シリアライズして署名を計算することです。キーの順序や空白が1文字違うだけで、署名はまったく別の値になります。署名の検証は、必ず受け取ったバイト列そのままで行う必要があります。

2つ目のミスは、比較の方法です。got == wantで比較すると、最初に異なるバイトで直ちに終わり、その時間差が、攻撃者に「最初の何文字かは合っていた」という情報を与えてしまいます。Pythonでは、hmac.compare_digestが、長さに比例する時間で比較します。

時刻ウィンドウ。署名が合っていても、それが今送られたものだという意味にはなりません。以前にやり取りされた有効なリクエストをそのまま再投入することをリプレイ(replay)と呼び、署名だけでは防げません。そこで、署名の材料にタイムスタンプを入れ、受け取る側が、現在時刻との差がウィンドウ(ふつう数分)の中にあるかを見ます。ウィンドウを狭くすると、時計が少しずれただけで正常なリクエストが拒否され、広くすると、リプレイが可能な区間が長くなります。

重複。キーが2つあることが落とし穴です。配信ID(delivery id)は1回の送信を指し、イベントID(event id)は起きた出来事1つを指します。同じ配信IDでまた来たならそれは再送で、配信IDは新しいのにイベントIDが同じなら、それも同じ出来事です。防ぐべきものは出来事の二重適用なので、本当のキーはイベントIDです。配信IDだけで除くと、後者を見逃します。

順序。届いた順序を信じず、イベントが持っているバージョンか発生時刻で判断します。現在保存されているものより新しいときだけ適用し、古いものは黙って捨てます。

POST /webhook
   │
   ├─ 서명 틀림 ──────────▶ 400 (원장에 손대지 않는다)
   ├─ 시각 창 밖 ─────────▶ 400
   └─ 통과 ─▶ 큐에 적재 ─▶ 200 (여기서 끝. 처리는 뒤에서)
                  │
                  └─ 배수(drain) ─▶ 중복인가? 더 새것인가? ─▶ 원장 적용

このコードブロックの韓国語は、署名が違う、時刻ウィンドウの外、通過、キューに積む、ドレイン、重複か、より新しいか、元帳に適用、という各分岐のラベルです。

早く200を返すことが、最後の1ピースです。受け取る場所で元帳まで触ると、処理が遅くなるほど相手のタイムアウトに引っかかり、そうすると再送が増え、再送が増えると処理がさらに遅くなります。検証だけしてキューに入れ、すぐに応答すれば、この悪循環は断ち切れます。

現場での姿

1つ目、「ときどき注文の状態が後戻りします」が、最もよくある報告です。原因は、ほとんどの場合、届いた順に上書きしたことです。ログを見ると、shippedのあとにpaidが適用されています。

2つ目、署名の検証がフレームワークの中で静かに壊れます。本文を自動でパースしてくれるフレームワークでは、元のバイト列を得る方法を別に探さなければなりません。プロキシが本文を書き換える場合(圧縮の展開、文字セットの変換)もあります。

2つ目の補足、シークレットの交換を計画に入れていません。シークレットを変えた瞬間に、それ以前に送られた配信がすべて拒否されます。そのため、交換期間には古いシークレットと新しいシークレットの両方を受け入れて、どちらかが合えば通します。

3つ目、キューが詰まったときに何を捨てるかを決めていません。Webhookは入り続けるので、キューは際限なく伸びます。出来事ごとにバージョンがあれば、同じ対象の古い出来事は捨ててよいのですが、その判断は事前に決めておく必要があります。

4つ目、相手が順序を守ってくれるとドキュメントに書かれていても、信じません。相手側の再試行が1回あるだけで、順序は崩れます。受け取る側にバージョンの比較があれば損はなく、なければ事故になります。

次のラボですること

注文イベントを送り込むパートナーの送信側サーバーを起動して、1日分の配信41件を手元に揃えます。その中には、再送、同じ出来事の新しい配信、古いリプレイの試み、シークレットを知らない側が作った偽物が混ざっています。署名検証器を作って定数時間で比較し、時刻ウィンドウを付け、配信IDとイベントIDの2つのキーで重複を除き、バージョンがより新しいときだけ適用するようにします。そのあと、早く200を返してキューに入れる受信エンドポイントを作り、最後に1日分を1件も漏らさず再び流し込んで、各分岐が何件だったかを集計します。