การตรวจสอบลายเซ็นและความปลอดภัย

Webhook การตรวจสอบลายเซ็น HMAC ความปลอดภัย replay attack
บทความนี้เหมาะสำหรับใคร
เหมาะสำหรับนักพัฒนาที่ต้องการสร้าง Webhook endpoint บทความนี้อธิบายวิธีการตรวจสอบลายเซ็นเพื่อยืนยันว่าคำขอที่เข้ามานั้นถูกส่งมาจาก ReceiptRoller จริง รวมถึงข้อควรระวังด้านความปลอดภัย

เนื่องจาก Webhook endpoint เป็น URL สาธารณะ โดยหลักการแล้วบุคคลที่สามสามารถส่ง POST ปลอมเข้ามาได้ เพื่อป้องกันปัญหานี้ ReceiptRoller จึงแนบลายเซ็นที่ใช้ HMAC-SHA256 ไปกับทุกคำขอ Webhook ฝั่งผู้รับต้องตรวจสอบลายเซ็นก่อนประมวลผลเสมอ endpoint ที่ไม่ตรวจสอบลายเซ็นจะไร้การป้องกันต่อการโจมตี

กลไกของลายเซ็น

ReceiptRoller จะคำนวณลายเซ็นตามขั้นตอนต่อไปนี้ก่อนส่ง

  1. สร้างสตริงเป้าหมายของลายเซ็น:{timestamp}.{request_body}
  2. คำนวณ HMAC-SHA256 โดยใช้ Webhook Secret เป็นคีย์
  3. เข้ารหัสผลลัพธ์เป็น hex
  4. แนบไปกับ HTTP header แล้วส่ง

Header ที่เกี่ยวข้องที่ถูกส่งมา

ชื่อ Header เนื้อหา
X-RR-Signature สตริง hex ของ HMAC-SHA256 (64 ตัวอักษร)
X-RR-Timestamp เวลาที่ส่ง (Unix วินาที)
X-RR-Event-Id Event ID (คีย์สำหรับ idempotency)
X-RR-Signature-Version เวอร์ชันของลายเซ็น (ปัจจุบันคือ v1)

ขั้นตอนการตรวจสอบฝั่งผู้รับ

  1. ดึง X-RR-Timestamp และตัวคำขอดิบ (raw request body) ออกมา (ไม่ใช่อ็อบเจกต์หลังจากพาร์ส แต่เป็นลำดับไบต์ที่ได้รับมานั้นเอง)
  2. ประกอบสตริงเป้าหมายของลายเซ็น {timestamp}.{body}
  3. คำนวณ HMAC-SHA256 ด้วย Webhook Secret ที่เก็บไว้ แล้วเข้ารหัสเป็น hex
  4. เปรียบเทียบผลลัพธ์ที่คำนวณได้กับ X-RR-Signature ด้วยการเปรียบเทียบแบบเวลาคงที่ (constant-time comparison) (ป้องกัน timing attack)
  5. ตรวจสอบว่า timestamp อยู่ในช่วง ±5 นาทีจากเวลาปัจจุบัน (ป้องกัน replay attack)

โค้ดตัวอย่าง

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 เพื่อเก็บ body ดิบไว้
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

  // ตรวจสอบ timestamp
  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");
  }

  // ตรวจสอบผ่าน:ประมวลผลอีเวนต์
  const event = JSON.parse(body.toString("utf8"));
  // ... ตรวจสอบ idempotency, ประมวลผลทางธุรกิจ ...
  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()  # ลำดับไบต์ดิบ

    # ตรวจสอบ timestamp
    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)

    # ตรวจสอบผ่าน:ประมวลผลอีเวนต์
    event = request.get_json()
    # ... ตรวจสอบ idempotency, ประมวลผลทางธุรกิจ ...
    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();

    // ตรวจสอบ timestamp
    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();

    // ตรวจสอบผ่าน:ประมวลผลอีเวนต์
    // ... ตรวจสอบ idempotency, ประมวลผลทางธุรกิจ ...
    return Ok();
}

ข้อผิดพลาดในการพัฒนาที่พบบ่อย

ข้อผิดพลาด ผลกระทบ การแก้ไข
คำนวณลายเซ็นจากสตริงหลังพาร์ส JSON ล้มเหลวทุกครั้งเมื่อช่องว่างหรือการขึ้นบรรทัดเปลี่ยนไป ใช้ลำดับไบต์ดิบเสมอ
เปรียบเทียบด้วย == ปกติ อาจถูกคาดเดาลายเซ็นได้จาก timing attack ใช้ฟังก์ชันเปรียบเทียบแบบเวลาคงที่
ไม่ตรวจสอบ timestamp คำขอที่เคยถูกต้องในอดีตอาจถูกส่งซ้ำ (replay attack) ทำช่วงยอมรับ ±5 นาที
เขียน secret ลงในโค้ดโดยตรง รั่วไหลเมื่อ repository หลุด ใช้ตัวแปรสภาพแวดล้อมหรือ secret manager
คืน 2xx เมื่อการตรวจสอบล้มเหลว ทำให้ผู้โจมตีเข้าใจผิดว่า "รับสำเร็จ" คืน 401

การจัดการ secret อย่างปลอดภัย

  • ที่จัดเก็บ:ตัวแปรสภาพแวดล้อม, AWS Secrets Manager, Azure Key Vault, GCP Secret Manager หรือบริการจัดการ secret อื่น ๆ
  • การควบคุมการเข้าถึง:ให้ secret ของ production อ่านได้เฉพาะ service account ของสภาพแวดล้อม production เท่านั้น
  • การหมุนเวียน (rotation):อย่างน้อยปีละครั้ง และทันทีเมื่อสงสัยว่ารั่วไหล รายละเอียดดูที่ 「การหมุนเวียนและการจัดเก็บ secret อย่างปลอดภัย」
  • การแยกสภาพแวดล้อม:ใช้ secret แยกกันสำหรับ production / staging / development
  • ห้ามเขียนลง log:อย่าเขียน secret หรือค่าลายเซ็นลงใน error log หรือ audit log

มาตรการเครือข่ายเพิ่มเติม (เลือกได้)

การตรวจสอบลายเซ็นเพียงอย่างเดียวก็เพียงพอแล้ว แต่หากต้องการดำเนินการอย่างเข้มงวดยิ่งขึ้น สามารถพิจารณาสิ่งต่อไปนี้ได้

  • IP allow list:เพิ่มช่วง IP ต้นทางที่ส่งจาก ReceiptRoller ลงใน allow list (เนื่องจากช่วง IP อาจเปลี่ยนแปลงได้โดยไม่แจ้งล่วงหน้า ควรใช้เป็นมาตรการเสริม ไม่ใช่แทนที่การตรวจสอบลายเซ็น)
  • WAF:วาง WAF ไว้หน้า endpoint เพื่อสกัดคำขอที่น่าสงสัย
  • การจำกัดอัตรา (rate limit):จำกัดคำขอที่มากเกินไปจาก IP เดียว

คู่มือที่เกี่ยวข้อง

วันที่เผยแพร่: 2569-04-27 วันที่อัปเดต: 2569-07-05
แท็ก
API (22) OAuth (15) Android (10) iOS (9) Webhook (8) api (7) oauth (5) getting-started (4) การลงทะเบียนแอป (4) การแก้ปัญหา (4)
บทความที่เกี่ยวข้อง