簽章驗證與安全性

Webhook 簽章驗證 HMAC 安全性 重放攻擊
本文的對象
適用於實作 Webhook 端點的開發者。說明確認送來的請求是否真的由 ReceiptRoller 發出的簽章驗證方法,與安全性上的注意點。

Webhook 端點是公開 URL,因此第三方原理上可能送入假的 POST。為防止此事,ReceiptRoller 為所有 Webhook 請求附帶 HMAC-SHA256 基底的簽章。接收端請務必驗證簽章後再處理。沒驗證的端點對攻擊而言毫無防備。

簽章的機制

ReceiptRoller 在發送前以下列步驟計算簽章。

  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 許可清單:將 ReceiptRoller 的發送來源 IP 範圍加入許可清單(IP 範圍可能會無預告變更,因此不作為簽章驗證的替代,而作為追加策)
  • WAF:在端點的前段放置 WAF,遮斷不審的請求
  • 速率限制:限制來自單一 IP 的過剩請求

相關指南

發布日: 2026-04-27 更新日: 2026-07-06