安全で便利な Webhook を作る
TweetWebhook をご存知でしょうか。GitHub やその他のサービスで提供されているあの機能です。 今回は弊社の Komoju API で Webhook を提供する際に気づいた、webhook 実装上の注意点について書きます。
内部のサーバーへのアクセスを禁止する
一般的な Webhook はユーザーが送信先となる任意のURLを指定して、サービスはそこに対してPOSTリクエストを送信します。 このときに気をつけないといけないのは「そのURLがサービス内部のホストかどうか」を識別しなければならないということです。 たとえば localhost とか、内部 DNS のドメインとかです。このような宛先への Webhook 送信を許可してしまうと、本来は外部から隔離されてアクセスできないはずのサーバーに Webhook送信サーバー経由でアクセスできることになってしまいます。
任意の形式のリクエストが送れるわけではないので、即これが脆弱性には繋がらないのですが、本来外部からはアクセスできないところにPOSTリクエストが送られるのは決して望ましい状態ではないでしょう。加えて、GitHubのように Webhook のリクエストログをユーザーに提供していると、そのログを元に攻撃者に不要な情報を与えてしまうことになりかねません。
ではどうすればいいかというと、きれいな解決策が思い浮かばず、禁止するホスト一覧をブラックリストとして持つくらいしか思いつきません。あるいは、やや大げさかもしれませんが Webhook を送信するサーバーを内部ネットワークから隔離された空間に配置するというのもいいかもしれません。
リダイレクトの追跡
送信先がリクエストに対して 3xx のレスポンスを返してきたらどのように対処すべきでしょうか。 個人的には以下の理由によりリダイレクトを追跡する必要はなく、エラーとして送信を終了すればよいと考えています。
- サービス利用者が送信先をリダイレクト先に設定すべき
- リダイレクトループに陥るのを防ぐ
いずれにしても、リダイレクトループを防ぐため追跡回数には上限を設けるべきですが、そのような複雑な実装や仕様にするよりも300番台のステータスはエラーとして記録するほうが提供者と利用者双方にとってシンプルでわかりやすいと考えます。
リトライ処理
それでは 4xxまたは5xxのエラーが返ってきた場合はどのように対処すべきでしょうか。考えたいのは失敗した Webhook をリトライするかしないか、するとしたらどのようなリトライ戦略が適切か、ということです。
これはサービスの性質によると思います。たとえば GitHub では Webhookのリトライはしません。一方で Komoju では確実に利用者に対して決済のステータス変更を通知する必要があるため、リトライを実施します。
リトライ間隔は「失敗したら一分後にリトライ」のような等間隔のリトライも考えられますが、これだと送信先がダウンしたり、あるいはAWSが落ちたりしたときにリトライ数が爆発してしまう可能性があるので、ランダム性を持たせた Exponential Backoff のような方法がよいのではないかと考えます。
ハッシュ値によるシグネチャ
Webhook の送信先が Webhook を受信したとき、どのようにしてそれが正当な送信元によるものと判断すればよいでしょうか。
まず、送信元IPアドレスによって判断するという方法があります。この方法には大きな問題があって、Webhook 提供側が事前に Webhook 送信元IPを示さなければならず、IP を容易に変更することができなくなってしまいます。 この問題に対する対策としては GitHub の Meta API のような手法が考えられます。つまり、送信元となる IP を API で提供し、送信元の IP をチェックするときは Meta API の値を参照してね、ということです。
もうひとつの方法として、Webhook にシグネチャを含める方法があります。両者で事前に鍵を共有しておき、送信の際に送信するボディと鍵を用いてHMACのハッシュを生成しリクエストのヘッダに含めます。
Komoju の Webhook では以下のようにヘッダに X-Komoju-Signature
というシグネチャを付与しています。
POST http://example.com/hook HTTP/1.1
Host: example.com
X-Komoju-Id: 5161cf27a77c3616084fdbd419cbeb09
X-Komoju-Signature: b268f32da3be1e0aa3db14c095d92c3d6943eb60b929842ef978304809273ebc
X-Komoju-Event: ping
User-Agent: Komoju-Webhook
Content-Type: application/json
Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept: */*
{
"type": "ping",
"object": "event",
"data": {
"url": "http://example.com/hook",
"object": "webhook",
"active": true,
"events": [
"ping",
"payment.captured"
]
},
"created_at": "2015-03-31T05:28:42Z"
}
このとき受信側は、シグネチャの比較に通常の文字列比較を使ってしまうとタイミングアタックによるハッキングが可能になるかもしれないので、以下の ruby の例のように安全な比較をするのが望ましいです。
require "sinatra"
WEBHOOK_SECRET_TOKEN = "keep it secret, keep it safe!"
post '/hook' do
request_body = request.body.read
signature = Digest::HMAC.hexdigest(request_body, WEBHOOK_SECRET_TOKEN, Digest::SHA2)
return 400 unless Rack::Utils.secure_compare(signature, request.env["HTTP_X_KOMOJU_SIGNATURE"])
"Hello World"
end
上記は主にエラーに対する扱いとセキュリティについてですが、これ以外にも、どのような情報を Webhook に含めるべきか、どのようなイベントを送信すべきか、といったことも当然検討しなければなりません。 指定された URL に POST リクエストを送信するだけの単純な機能ですが、意外と考慮することが多いですね。この記事が Webhook を実装する際の参考になることを願っています。