署名検証とセキュリティ

Webhook 署名検証 HMAC セキュリティ リプレイ攻撃
この記事の対象
Webhookエンドポイントを実装する開発者向けです。届いたリクエストが本当にレシートローラーから送られたものかを確認する署名検証の方法と、セキュリティ上の注意点を説明します。

Webhookエンドポイントは公開URLなので、第三者が偽のPOSTを送り込むことが原理的に可能です。これを防ぐため、レシートローラーは全てのWebhookリクエストに HMAC-SHA256 ベースの署名を付与しています。受信側は必ず署名を検証してから処理してください。検証していないエンドポイントは攻撃に対して無防備です。

署名の仕組み

レシートローラーは送信前に次の手順で署名を計算します。

  1. 署名対象文字列を作る:{timestamp}.{request_body}
  2. Webhook Secret をキーとして HMAC-SHA256 を計算
  3. 結果を hex エンコード
  4. HTTPヘッダーに付与して送信

送信される関連ヘッダー

ヘッダー名 内容
X-RR-Signature HMAC-SHA256 の hex 文字列(64文字)
X-RR-Timestamp 配信時刻(Unix秒)
X-RR-Event-Id イベントID(冪等性キー)
X-RR-Signature-Version 署名バージョン(現行は v1

受信側の検証手順

  1. X-RR-Timestamp生のリクエストボディを取り出す(パース後のオブジェクトではなく、受信したバイト列そのもの)
  2. 署名対象文字列 {timestamp}.{body} を組み立てる
  3. 保管している Webhook Secret で HMAC-SHA256 を計算し hex エンコード
  4. 計算結果と X-RR-Signature定数時間比較で照合(タイミング攻撃対策)
  5. タイムスタンプが現在時刻から ±5分以内であることを確認(リプレイ攻撃対策)

サンプルコード

Node.js(Express)

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

const SECRET = process.env.RR_WEBHOOK_SECRET;
const TOLERANCE_SEC = 300; // 5分

// 重要:生ボディを保持するため express.raw を使う
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.header("X-RR-Signature");
  const ts  = req.header("X-RR-Timestamp");
  const body = req.body; // Buffer

  // タイムスタンプ検証
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(ts, 10)) > TOLERANCE_SEC) {
    return res.status(401).send("timestamp out of tolerance");
  }

  // 署名検証
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${ts}.${body.toString("utf8")}`)
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).send("invalid signature");
  }

  // 検証OK:イベントを処理
  const event = JSON.parse(body.toString("utf8"));
  // ... 冪等性チェック、業務処理 ...
  res.status(200).send("ok");
});

Python(Flask)

import hmac, hashlib, time, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["RR_WEBHOOK_SECRET"].encode()
TOLERANCE_SEC = 300

@app.route("/webhook", methods=["POST"])
def webhook():
    sig = request.headers.get("X-RR-Signature", "")
    ts  = request.headers.get("X-RR-Timestamp", "")
    body = request.get_data()  # 生バイト列

    # タイムスタンプ検証
    if abs(int(time.time()) - int(ts)) > TOLERANCE_SEC:
        abort(401)

    # 署名検証
    payload = f"{ts}.".encode() + body
    expected = hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        abort(401)

    # 検証OK:イベントを処理
    event = request.get_json()
    # ... 冪等性チェック、業務処理 ...
    return "ok", 200

C#(ASP.NET Core)

[HttpPost("/webhook")]
public async Task<IActionResult> Receive()
{
    Request.EnableBuffering();
    using var reader = new StreamReader(Request.Body);
    var body = await reader.ReadToEndAsync();

    var sig = Request.Headers["X-RR-Signature"].ToString();
    var ts  = Request.Headers["X-RR-Timestamp"].ToString();

    // タイムスタンプ検証
    var nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
    if (Math.Abs(nowSec - long.Parse(ts)) > 300) return Unauthorized();

    // 署名検証
    var secret = Encoding.UTF8.GetBytes(_config["RR_WEBHOOK_SECRET"]);
    using var h = new HMACSHA256(secret);
    var expected = Convert.ToHexString(
        h.ComputeHash(Encoding.UTF8.GetBytes($"{ts}.{body}"))
    ).ToLowerInvariant();

    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(sig),
            Encoding.UTF8.GetBytes(expected)))
        return Unauthorized();

    // 検証OK:イベントを処理
    // ... 冪等性チェック、業務処理 ...
    return Ok();
}

よくある実装ミス

ミス 影響 対処
JSONパース後の文字列で署名計算 空白・改行が変わり毎回失敗 必ず生バイト列を使う
通常の == で比較 タイミング攻撃で署名を推測される可能性 定数時間比較関数を使う
タイムスタンプを検証しない 過去の有効リクエストを再送される(リプレイ攻撃) ±5分の許容範囲を実装
シークレットをコードに直書き リポジトリ流出時に漏えい 環境変数またはシークレットマネージャーへ
検証失敗時に 2xx を返す 攻撃者に「受信成功」と誤認させる 401 を返す

シークレットの安全な管理

  • 保管場所:環境変数、AWS Secrets Manager、Azure Key Vault、GCP Secret Manager などのシークレット管理サービス
  • アクセス制御:本番シークレットは本番環境のサービスアカウントのみが読める状態に
  • ローテーション:少なくとも年1回、漏えい疑い時は即時。詳細は「シークレットのローテーションと安全な保管」を参照
  • 環境分離:本番/ステージング/開発で別シークレットを使う
  • ログ出力禁止:エラーログや監査ログにシークレットや署名値を出力しない

追加のネットワーク対策(任意)

署名検証で十分ですが、より厳格に運用したい場合は以下も検討できます。

  • IP許可リスト:レシートローラーの送信元IPレンジを許可リストに追加(IPレンジは予告なく変更される可能性があるため、署名検証の代替ではなく追加策として)
  • WAF:エンドポイントの前段にWAFを置き、不審なリクエストを遮断
  • レート制限:単一IPからの過剰リクエストを制限

関連ガイド

公開日: 2026-04-27 更新日: 2026-04-27
タグ
API (8) Webhook (8) api (6) oauth (5) トラブル (5) OAuth (4) getting-started (4) アプリ登録 (4) app-registration (3) webhook (3)
関連記事