メインコンテンツへスキップ

Webhookセキュリティ

Omise Webhookをセキュアに実装するための完全なガイド。HMAC-SHA256署名検証、タイミング安全な比較、キー管理、リプレイ攻撃防止について説明します。

概要

Webhookセキュリティは、Omiseからのイベントが正当で改ざんされていないことを確保し、不正なリクエストから保護するために重要です。すべてのWebhookはHMAC-SHA256署名で署名されており、Webhookシークレットキーを使用して検証できます。

HMAC-SHA256署名検証

署名検証プロセス

WebhookはX-Omise-Signatureヘッダーに署名が含まれます。署名は以下のように計算されます:

signature = HMAC-SHA256(webhook_secret_key, request_body)

Node.jsでの実装

const crypto = require('crypto');
const express = require('express');
const app = express();

// Webhookシークレットキー (環境変数から)
const webhookKey = process.env.OMISE_WEBHOOK_KEY;

// Rawボディを保存
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString('utf8');
}
}));

// シグネチャを検証する関数
function verifyWebhookSignature(body, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');

// タイミング安全な比較を使用
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}

// Webhookエンドポイント
app.post('/webhooks/omise', (req, res) => {
const signature = req.headers['x-omise-signature'];

try {
// シグネチャを検証
if (!verifyWebhookSignature(req.rawBody, signature, webhookKey)) {
console.error('Invalid webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}

// Webhookを処理
const event = req.body;
processEvent(event);

res.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});

function processEvent(event) {
console.log(`Processing event: ${event.key}`);
// イベント処理ロジック
}

module.exports = app;

Pythonでの実装

import hmac
import hashlib
from flask import Flask, request, jsonify

app = Flask(__name__)

# Webhookシークレットキー (環境変数から)
WEBHOOK_KEY = os.environ.get('OMISE_WEBHOOK_KEY')

def verify_webhook_signature(body, signature, secret):
"""HMAC-SHA256署名を検証"""
expected_signature = hmac.new(
secret.encode('utf-8'),
body.encode('utf-8') if isinstance(body, str) else body,
hashlib.sha256
).hexdigest()

# タイミング安全な比較を使用
return hmac.compare_digest(signature, expected_signature)

@app.route('/webhooks/omise', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Omise-Signature', '')
payload = request.get_data(as_text=True)

try:
# シグネチャを検証
if not verify_webhook_signature(payload, signature, WEBHOOK_KEY):
print('Invalid webhook signature')
return jsonify({'error': 'Invalid signature'}), 401

# Webhookを処理
event = request.json
process_event(event)

return jsonify({'received': True}), 200
except Exception as e:
print(f'Webhook error: {e}')
return jsonify({'error': 'Internal server error'}), 500

def process_event(event):
print(f"Processing event: {event['key']}")
# イベント処理ロジック

Rubyでの実装

require 'sinatra'
require 'json'
require 'openssl'

# Webhookシークレットキー (環境変数から)
WEBHOOK_KEY = ENV['OMISE_WEBHOOK_KEY']

def verify_webhook_signature(body, signature, secret)
expected_signature = OpenSSL::HMAC.hexdigest(
OpenSSL::Digest.new('sha256'),
secret,
body
)

# タイミング安全な比較を使用
Rack::Utils.secure_compare(signature, expected_signature)
end

post '/webhooks/omise' do
signature = request.headers['X-Omise-Signature']
payload = request.body.read
request.body.rewind

begin
# シグネチャを検証
unless verify_webhook_signature(payload, signature, WEBHOOK_KEY)
puts 'Invalid webhook signature'
halt 401, { error: 'Invalid signature' }.to_json
end

# Webhookを処理
event = JSON.parse(payload)
process_event(event)

{ received: true }.to_json
rescue => e
puts "Webhook error: #{e.message}"
halt 500, { error: 'Internal server error' }.to_json
end
end

def process_event(event)
puts "Processing event: #{event['key']}"
# イベント処理ロジック
end

PHPでの実装

<?php
// Webhookシークレットキー (環境変数から)
$webhookKey = getenv('OMISE_WEBHOOK_KEY');

function verifyWebhookSignature($payload, $signature, $secret) {
$expectedSignature = hash_hmac('sha256', $payload, $secret);

// タイミング安全な比較を使用
return hash_equals($expectedSignature, $signature);
}

// Webhookエンドポイント
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_OMISE_SIGNATURE'] ?? '';

try {
// シグネチャを検証
if (!verifyWebhookSignature($payload, $signature, $webhookKey)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}

// Webhookを処理
$event = json_decode($payload, true);
processEvent($event);

http_response_code(200);
echo json_encode(['received' => true]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => 'Internal server error']);
}

function processEvent($event) {
echo "Processing event: {$event['key']}\n";
// イベント処理ロジック
}
?>

Goでの実装

package main

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
)

// Webhookシークレットキー (環境変数から)
var webhookKey = os.Getenv("OMISE_WEBHOOK_KEY")

func verifyWebhookSignature(body []byte, signature, secret string) bool {
h := hmac.New(sha256.New, []byte(secret))
h.Write(body)
expectedSignature := hex.EncodeToString(h.Sum(nil))

return hmac.Equal([]byte(signature), []byte(expectedSignature))
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-Omise-Signature")

// ボディを読み込む
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error reading request body", http.StatusBadRequest)
return
}
defer r.Body.Close()

// シグネチャを検証
if !verifyWebhookSignature(body, signature, webhookKey) {
log.Println("Invalid webhook signature")
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}

// Webhookを処理
var event map[string]interface{}
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "Error parsing JSON", http.StatusBadRequest)
return
}

processEvent(event)

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}

func processEvent(event map[string]interface{}) {
fmt.Printf("Processing event: %v\n", event["key"])
// イベント処理ロジック
}

func main() {
http.HandleFunc("/webhooks/omise", handleWebhook)
log.Fatal(http.ListenAndServe(":3000", nil))
}

セキュリティベストプラクティス

1. Rawボディを使用

署名を検証する場合、常にrawリクエストボディを使用してください。パースされたJSONを使用しないでください:

// ✓ 正しい
const body = req.rawBody;

// ✗ 不正
const body = JSON.stringify(req.body);

2. タイミング安全な比較を使用

タイミング攻撃を防ぐため、タイミング安全な比較関数を使用:

// ✓ 正しい - タイミング安全
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

// ✗ 不正 - タイミング攻撃に対して脆弱
signature === expected;

3. Webhookキーを安全に保存

シークレットキーを環境変数またはシークレットマネージャーに保存:

// ✓ 正しい
const key = process.env.OMISE_WEBHOOK_KEY;

// ✗ 不正
const key = 'whsec_xxxxx'; // コードに直接

4. シークレットローテーション

定期的にWebhookキーをローテーション:

// 複数キーをサポート
const primaryKey = process.env.OMISE_WEBHOOK_KEY_PRIMARY;
const secondaryKey = process.env.OMISE_WEBHOOK_KEY_SECONDARY;

function verifySignature(body, signature) {
return verifyWithKey(body, signature, primaryKey) ||
verifyWithKey(body, signature, secondaryKey);
}

5. リプレイ攻撃を防止

イベントIDとタイムスタンプをトラッキング:

function handleWebhook(event) {
const eventId = event.id;
const eventTime = event.created;
const now = Math.floor(Date.now() / 1000);

// タイムスタンプをチェック (5分以内)
if (now - eventTime > 300) {
console.error('Event is too old');
return;
}

// すでに処理済みかどうかを確認
if (processedEvents.has(eventId)) {
console.log('Duplicate event');
return;
}

processedEvents.add(eventId);
// イベントを処理
}

トラブルシューティング

シグネチャ検証に失敗

原因:

  • Rawボディの代わりに解析済みJSONを使用
  • 間違ったシークレットキー
  • エンコーディング問題

チェック:

  1. Rawリクエストボディを使用していることを確認
  2. Webhookシークレットキーをダッシュボードから確認
  3. UTF-8エンコーディングを確認

セキュリティの設定ミス

ベストプラクティス:

  • すべてのWebhookエンドポイントにHTTPSを使用
  • Webhookシークレットキーをコミットしない
  • シークレットキーをログに記録しない
  • 署名検証をテストしてください

FAQ

Webhook署名キーはどこで取得できますか?

Webhook署名キーは、Omiseダッシュボードで新しいWebhookエンドポイントを作成するときに表示されます。一度しか表示されないので、安全に保存してください。紛失した場合は、新しいWebhookエンドポイントを作成してください。

テストモードと本番モードで同じWebhookキーを使用できますか?

いいえ、テストモードと本番モードは別々のWebhookエンドポイントと署名キーを持っています。各モードで異なるエンドポイントを設定してください。

署名検証が失敗した場合はどうなりますか?

HTTP 401 Unauthorizedを返します。Omiseは再試行スケジュールに従ってWebhookの配信を再試行します。

テストモードのWebhookでも署名を検証する必要がありますか?

はい、本番運用前に検証コードが正しく動作することを確認するために、テストモードと本番モードの両方で常に署名を検証してください。

Webhookキーはどのくらいの頻度でローテーションすべきですか?

6〜12ヶ月ごと、または漏洩の疑いがある場合は直ちにキーをローテーションしてください。ダウンタイムを避けるために、デュアルキー検証を実装してください。

署名検証の代わりにOmiseのIPアドレスをホワイトリストに登録できますか?

いいえ、IPホワイトリストだけでは不十分です。IPアドレスはスプーフィングされる可能性があり、変更される可能性もあるため、常に署名を検証してください。

リプレイ攻撃を検出した場合はどうすればよいですか?

イベントをログに記録し、再試行を防ぐために200 OKで応答し、パターンを監視します。攻撃が継続する場合はセキュリティチームに警告してください。

処理済みのイベントIDはどのくらいの期間保存すべきですか?

少なくとも7日間(Omiseの再試行期間)イベントIDを保存してください。より長い保持期間(30日)でより良い保護が得られます。

署名検証を同期的に行うことはできますか?

はい、署名検証は高速です(数ミリ秒)。Webhookに応答する前に同期的に検証してください。

Webhookキーが漏洩した場合はどうすればよいですか?

直ちに新しいキーで新しいWebhookエンドポイントを作成し、アプリケーションを更新し、漏洩したエンドポイントを無効化してください。

関連リソース

次のステップ

  1. HMAC-SHA256署名検証を実装
  2. タイミング安全な比較を使用
  3. Webhookキーを安全に保存
  4. リプレイ攻撃防止を実装
  5. 定期的にシークレットキーをローテーション
  6. Webhookセキュリティをテスト